Add the /mcp wizard for local and remote servers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
co-authored by
Sisyphus
parent
e23ec053ae
commit
e06fbc3050
@@ -17,6 +17,7 @@ export type CommandAction =
|
||||
| { type: 'skills' }
|
||||
| { type: 'plugins' }
|
||||
| { type: 'registry'; action: 'list' | 'search' | 'add' | 'remove' | 'installed'; arg?: string }
|
||||
| { type: 'mcp'; action: 'list' | 'add' | 'remove'; arg?: string }
|
||||
| { type: 'memory' }
|
||||
| { type: 'agent'; agent?: string }
|
||||
| { type: 'think'; level?: string }
|
||||
@@ -44,6 +45,7 @@ export const COMMANDS: CommandSpec[] = [
|
||||
{ name: 'skills', summary: 'list loaded skills' },
|
||||
{ name: 'plugins', summary: 'list active plugins' },
|
||||
{ name: 'registry', arg: '[search|add|remove] [name]', summary: 'browse and install external skills and plugins' },
|
||||
{ name: 'mcp', arg: '[add|remove <name>]', summary: 'add a local or remote MCP server, or list them' },
|
||||
{ name: 'init', summary: 'have the agent write AGENTS.md for this project' },
|
||||
{ name: 'context', summary: 'show which instruction files are loaded' },
|
||||
{ name: 'todos', summary: "show the agent's task list" },
|
||||
@@ -127,6 +129,33 @@ function parseRegistry(arg: string): CommandAction {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/mcp [list|add|remove <name>]`.
|
||||
*
|
||||
* A bare `/mcp` lists what is configured, because that is the question asked most
|
||||
* often. `add` opens the wizard rather than taking arguments: a server is a name
|
||||
* plus a command or a URL plus optional headers, and a single argument string
|
||||
* cannot express that without a syntax nobody remembers.
|
||||
*/
|
||||
function parseMcp(arg: string): CommandAction {
|
||||
const [verb = '', ...rest] = arg.split(/\s+/).filter(Boolean);
|
||||
const name = rest.join(' ').trim();
|
||||
|
||||
switch (verb) {
|
||||
case '':
|
||||
case 'list':
|
||||
return { type: 'mcp', action: 'list' };
|
||||
case 'add':
|
||||
case 'new':
|
||||
return { type: 'mcp', action: 'add' };
|
||||
case 'remove':
|
||||
case 'rm':
|
||||
return name ? { type: 'mcp', action: 'remove', arg: name } : { type: 'info', text: 'usage: /mcp remove <name>' };
|
||||
default:
|
||||
return { type: 'info', text: 'usage: /mcp [list|add|remove <name>]' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure parser: no IO, so the TUI and headless mode share one definition. */
|
||||
export function parseCommand(raw: string): CommandAction {
|
||||
const input = raw.trim();
|
||||
@@ -174,6 +203,8 @@ export function parseCommand(raw: string): CommandAction {
|
||||
return { type: 'plugins' };
|
||||
case 'registry':
|
||||
return parseRegistry(arg);
|
||||
case 'mcp':
|
||||
return parseMcp(arg);
|
||||
case 'memory':
|
||||
return { type: 'memory' };
|
||||
case 'agent':
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import TextInput from 'ink-text-input';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { McpServerConfig } from '../mcp';
|
||||
import { Frame as Panel, Row } from './Pickers';
|
||||
|
||||
/** The shared frame plus the cancel hint every step of this wizard carries. */
|
||||
function Frame({ children, ...rest }: React.ComponentProps<typeof Panel>) {
|
||||
return (
|
||||
<Panel {...rest}>
|
||||
{children}
|
||||
<Text dimColor>esc to cancel</Text>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export type McpAddResult = { name: string; config: McpServerConfig };
|
||||
|
||||
type Kind = 'local' | 'remote';
|
||||
|
||||
type Step =
|
||||
| { name: 'pick-kind' }
|
||||
| { name: 'server-name'; kind: Kind }
|
||||
| { name: 'command'; server: string }
|
||||
| { name: 'args'; server: string; command: string }
|
||||
| { name: 'url'; server: string }
|
||||
| { name: 'headers'; server: string; url: string };
|
||||
|
||||
/**
|
||||
* A server name has to survive being spliced into a tool name.
|
||||
*
|
||||
* Tools are registered as `mcp__<server>__<tool>`, so a name containing `__` or a
|
||||
* space produces a tool the model cannot reliably call and two servers whose
|
||||
* namespaces can collide. Rejecting it here beats a confusing failure at connect.
|
||||
*/
|
||||
export function invalidName(name: string): string | undefined {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) return 'a name is required';
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) return 'use letters, digits, and hyphens only';
|
||||
if (trimmed.includes('__')) return 'double underscores clash with the mcp__server__tool naming';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** `KEY: value, OTHER: value` into a header object. Empty input means no headers. */
|
||||
export function parseHeaders(raw: string): Record<string, string> | undefined {
|
||||
const pairs = raw
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const at = part.indexOf(':');
|
||||
return at === -1 ? undefined : ([part.slice(0, at).trim(), part.slice(at + 1).trim()] as const);
|
||||
})
|
||||
.filter((p): p is readonly [string, string] => p !== undefined && p[0].length > 0);
|
||||
|
||||
return pairs.length > 0 ? Object.fromEntries(pairs) : undefined;
|
||||
}
|
||||
|
||||
/** Splits a command line on spaces, keeping quoted runs together. */
|
||||
export function splitArgs(raw: string): string[] {
|
||||
const matched = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
||||
return matched.map((a) => a.replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one MCP server, local or remote, without hand-editing config.json.
|
||||
*
|
||||
* The two kinds need different fields — a command and its arguments against a URL
|
||||
* and its headers — so the wizard branches rather than showing a form with half of
|
||||
* it inapplicable. It collects and returns; writing the config is the caller's.
|
||||
*/
|
||||
export function McpAdd({
|
||||
existing,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
existing: string[];
|
||||
onDone: (result: McpAddResult) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<Step>({ name: 'pick-kind' });
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) onCancel();
|
||||
});
|
||||
|
||||
const advance = useCallback((next: Step) => {
|
||||
setDraft('');
|
||||
setError(undefined);
|
||||
setStep(next);
|
||||
}, []);
|
||||
|
||||
const submitName = useCallback(
|
||||
(kind: Kind, value: string) => {
|
||||
const bad = invalidName(value);
|
||||
if (bad) return setError(bad);
|
||||
const server = value.trim();
|
||||
if (existing.includes(server)) return setError(`${server} is already configured`);
|
||||
advance(kind === 'local' ? { name: 'command', server } : { name: 'url', server });
|
||||
},
|
||||
[advance, existing],
|
||||
);
|
||||
|
||||
switch (step.name) {
|
||||
case 'pick-kind':
|
||||
return (
|
||||
<Frame
|
||||
title="Add an MCP server"
|
||||
hint={existing.length > 0 ? `${existing.length} configured: ${existing.join(', ')}` : 'none configured yet'}
|
||||
>
|
||||
<SelectInput
|
||||
items={[
|
||||
{ key: 'local', label: 'local a command on this machine, over stdio', value: 'local' },
|
||||
{ key: 'remote', label: 'remote an http or sse endpoint', value: 'remote' },
|
||||
]}
|
||||
onSelect={(item) => advance({ name: 'server-name', kind: item.value as Kind })}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'server-name':
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.kind === 'local' ? 'Local' : 'Remote'} server: name`}
|
||||
hint="used in the tool name as mcp__<name>__<tool>"
|
||||
error={error}
|
||||
>
|
||||
<Row label="name">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => submitName(step.kind, v)}
|
||||
placeholder="filesystem"
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'command':
|
||||
return (
|
||||
<Frame title={`${step.server}: command`} hint="the executable to run, e.g. npx or bun" error={error}>
|
||||
<Row label="command">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) =>
|
||||
v.trim() ? advance({ name: 'args', server: step.server, command: v.trim() }) : setError('a command is required')
|
||||
}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'args':
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.server}: arguments`}
|
||||
hint={`${step.command} <args> - enter with nothing to pass none`}
|
||||
>
|
||||
<Row label="args">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => {
|
||||
const args = splitArgs(v.trim());
|
||||
onDone({
|
||||
name: step.server,
|
||||
config: { command: step.command, ...(args.length > 0 ? { args } : {}) },
|
||||
});
|
||||
}}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem ."
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'url':
|
||||
return (
|
||||
<Frame title={`${step.server}: endpoint URL`} hint="http or https, the server's MCP endpoint" error={error}>
|
||||
<Row label="url">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => {
|
||||
const url = v.trim();
|
||||
if (!/^https?:\/\//i.test(url)) return setError('the URL must start with http:// or https://');
|
||||
advance({ name: 'headers', server: step.server, url });
|
||||
}}
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'headers':
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.server}: headers`}
|
||||
hint="KEY: value, comma separated - enter with nothing for none"
|
||||
>
|
||||
<Row label="headers">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => {
|
||||
const headers = parseHeaders(v);
|
||||
onDone({
|
||||
name: step.server,
|
||||
config: { url: step.url, ...(headers ? { headers } : {}) },
|
||||
});
|
||||
}}
|
||||
placeholder="Authorization: Bearer sk-..."
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user