158 lines
6.4 KiB
JavaScript
158 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* The interactive entry point: a chat REPL with no built-in tools — everything comes from the
|
|
* MCP servers in your config. Run it from the repo root:
|
|
*
|
|
* pnpm --filter @mcp-examples/cli-client start # sibling todos-server, scripted provider
|
|
* ANTHROPIC_API_KEY=… pnpm --filter @mcp-examples/cli-client start -- --provider anthropic
|
|
* pnpm --filter @mcp-examples/cli-client start -- --config ./config.json --provider openai
|
|
* pnpm --filter @mcp-examples/cli-client start -- --server https://mcp.linear.app/mcp # one ad-hoc server, OAuth if needed
|
|
*/
|
|
import { existsSync } from 'node:fs';
|
|
import { createInterface } from 'node:readline/promises';
|
|
import { parseArgs } from 'node:util';
|
|
|
|
import type { CliClientConfig } from './host/config';
|
|
import { configFromTargets, readConfigFile, todosServerConfig } from './host/config';
|
|
import { McpHost } from './host/host';
|
|
import { createSession, handleUserInput } from './host/loop';
|
|
import { createCompleter, ReadlineUI } from './host/ui';
|
|
import { AnthropicProvider } from './providers/anthropic';
|
|
import { GeminiProvider } from './providers/gemini';
|
|
import { OpenAIProvider } from './providers/openai';
|
|
import type { LLMProvider } from './providers/provider';
|
|
import { ScriptedProvider } from './providers/scripted';
|
|
|
|
const USAGE = `usage: tsx cli.ts [options]
|
|
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
|
|
--config <path> mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server)
|
|
--provider <name> scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted)
|
|
--model <id> pin a model id (default: the provider's latest mid-tier model)
|
|
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
|
|
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH)
|
|
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
|
|
--protocol-version <v> negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin
|
|
--help this help`;
|
|
|
|
function pickProvider(name: string | undefined, model: string | undefined): LLMProvider {
|
|
const chosen =
|
|
name ??
|
|
(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
|
|
? 'anthropic'
|
|
: process.env.OPENAI_API_KEY
|
|
? 'openai'
|
|
: process.env.GEMINI_API_KEY
|
|
? 'gemini'
|
|
: 'scripted');
|
|
switch (chosen) {
|
|
case 'anthropic': {
|
|
return new AnthropicProvider(model);
|
|
}
|
|
case 'openai': {
|
|
return new OpenAIProvider(model);
|
|
}
|
|
case 'gemini': {
|
|
return new GeminiProvider(model);
|
|
}
|
|
case 'scripted': {
|
|
return new ScriptedProvider();
|
|
}
|
|
default: {
|
|
throw new Error(`Unknown provider "${chosen}" (expected scripted | anthropic | openai | gemini)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const { values } = parseArgs({
|
|
// `pnpm … start -- --provider anthropic` forwards the literal `--`; drop it so only flags remain.
|
|
args: process.argv.slice(2).filter(argument => argument !== '--'),
|
|
options: {
|
|
server: { type: 'string', multiple: true },
|
|
config: { type: 'string' },
|
|
provider: { type: 'string' },
|
|
model: { type: 'string' },
|
|
root: { type: 'string', multiple: true },
|
|
'callback-port': { type: 'string' },
|
|
legacy: { type: 'boolean' },
|
|
'protocol-version': { type: 'string' },
|
|
help: { type: 'boolean', short: 'h' }
|
|
}
|
|
});
|
|
|
|
if (values.help) {
|
|
console.log(USAGE);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Tab completion needs the host's cached lists, but the host needs the UI — resolve lazily.
|
|
const hostRef: { current?: McpHost } = {};
|
|
const ui = new ReadlineUI(
|
|
createInterface({ input: process.stdin, output: process.stdout, completer: createCompleter(() => hostRef.current) })
|
|
);
|
|
const provider = pickProvider(values.provider, values.model);
|
|
|
|
let config: CliClientConfig;
|
|
let configSource: string;
|
|
if (values.server && values.server.length > 0) {
|
|
configSource = '--server arguments';
|
|
config = configFromTargets(values.server);
|
|
} else if (values.config) {
|
|
configSource = values.config;
|
|
config = await readConfigFile(values.config);
|
|
} else if (existsSync('./config.json')) {
|
|
configSource = './config.json';
|
|
config = await readConfigFile('./config.json');
|
|
} else {
|
|
configSource = 'sibling todos-server (no config.json found — see config.example.json)';
|
|
config = todosServerConfig();
|
|
}
|
|
|
|
// Show exactly what we are about to connect to before doing it.
|
|
ui.status(`config: ${configSource}`);
|
|
for (const [serverName, entry] of Object.entries(config.mcpServers)) {
|
|
ui.status(` ${serverName} → ${'url' in entry ? entry.url : [entry.command, ...(entry.args ?? [])].join(' ')}`);
|
|
}
|
|
|
|
let host: McpHost;
|
|
try {
|
|
host = new McpHost({
|
|
ui,
|
|
provider,
|
|
roots: values.root ?? [process.cwd()],
|
|
legacy: values.legacy ?? false,
|
|
protocolVersion: values['protocol-version'],
|
|
oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined
|
|
});
|
|
hostRef.current = host;
|
|
await host.connect(config);
|
|
} catch (error) {
|
|
ui.print(error instanceof Error ? error.message : String(error));
|
|
ui.close();
|
|
process.exit(1);
|
|
}
|
|
|
|
if (provider.name === 'scripted') {
|
|
ui.status(
|
|
'provider: scripted (no API key found — replies are canned; set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY or pass --provider)'
|
|
);
|
|
} else {
|
|
ui.status(`provider: ${provider.name}`);
|
|
}
|
|
ui.print('cli-client ready — say hi for a tour, /help for commands, /quit to exit.');
|
|
|
|
const chat = createSession(host, provider, ui);
|
|
try {
|
|
for (;;) {
|
|
const input = await ui.readUserInput();
|
|
try {
|
|
const result = await handleUserInput(chat, input);
|
|
if (result === 'exit') break;
|
|
} catch (error) {
|
|
// A provider hiccup or a server error should cost one turn, not the whole session.
|
|
ui.status(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
} finally {
|
|
await host.close();
|
|
ui.close();
|
|
}
|