feat(examples): add a reference host/server pair — cli-client + todos-server (#2380)

This commit is contained in:
Felix Weinberger 2026-06-29 13:58:32 +01:00 • committed by GitHub
commit ba27d201e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 4698 additions and 25 deletions

View file

@ -16,6 +16,8 @@ pnpm --filter @mcp-examples/<story> client -- --http http://127.0.0.1:3000/mcp
Add `-- --legacy` to the client command for the 2025-era handshake.
The one exception to the generic commands is the reference pair: [`cli-client/`](./cli-client/README.md) and [`todos-server/`](./todos-server/README.md) have their own entry points (`pnpm --filter @mcp-examples/cli-client start`, `pnpm --filter @mcp-examples/todos-server start:http`) — see their READMEs.
## Start here
| Story | What it teaches |
@ -27,25 +29,27 @@ Add `-- --legacy` to the client command for the 2025-era handshake.
## Feature stories
| Story | What it teaches | Transports | Era |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | -------------- |
| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern |
| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern |
| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual |
| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual |
| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual |
| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual |
| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern |
| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern |
| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual |
| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual |
| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy |
| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual |
| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) |
| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual |
| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual |
| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual |
| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern |
| Story | What it teaches | Transports | Era |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------- |
| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern |
| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern |
| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual |
| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual |
| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual |
| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual |
| [`cli-client/`](./cli-client/README.md) | **Reference host**: LLM chat CLI with provider seam — tool loop, @-mention resources, prompt commands, sampling, elicitation, roots, OAuth, cancellation | stdio + http | dual |
| [`todos-server/`](./todos-server/README.md) | **Reference server** (pairs with cli-client): every server feature with a real job — CRUD tools, sampling, multi-round elicitation, subscriptions, progress | stdio + http | dual |
| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern |
| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern |
| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual |
| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual |
| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy |
| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual |
| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) |
| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual |
| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual |
| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual |
| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern |
## HTTP hosting variants

1
examples/cli-client/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
config.json

View file

@ -0,0 +1,157 @@
# cli-client — the reference MCP host
An interactive, LLM-connected chat CLI with **no built-in tools**: everything the model can do comes from the MCP servers you connect it to. It is a minimal but complete host — every client-side MCP feature is wired the way a host application should wire it — built to be read and copied from.
Its standard workload is [`examples/todos-server`](../todos-server/README.md), the reference server, but it connects to **any** MCP server: a URL, a command line, or an `mcpServers`-style config file.
## Quick start (no API key)
From the repo root (first time: `pnpm install && pnpm build:all`):
```bash
pnpm --filter @mcp-examples/cli-client start
```
That spawns todos-server over stdio and answers with the keyless `scripted` provider — enough to see the wiring move. For a real conversation, add a provider key (next section), then say `hi`: the model offers a guided tour that walks through every feature.
## Providers
The model sits behind one small interface (`providers/provider.ts`); each file in `providers/` is a complete, copyable mapping for one vendor. Pick one explicitly with `--provider`, or let the CLI auto-pick from the environment (checked in this order):
| Provider | Enable with | Default model | Pin a model |
| ----------- | -------------------------------------------------------------- | ------------------------------------------- | ----------------------------------- |
| `anthropic` | `ANTHROPIC_API_KEY` (or an OAuth-style `ANTHROPIC_AUTH_TOKEN`) | newest Sonnet, resolved from the models API | `--model <id>` or `ANTHROPIC_MODEL` |
| `openai` | `OPENAI_API_KEY` | newest mainline GPT (non-pro) | `--model <id>` or `OPENAI_MODEL` |
| `gemini` | `GEMINI_API_KEY` | newest stable Flash | `--model <id>` or `GEMINI_MODEL` |
| `scripted` | nothing — the keyless default | n/a (replays canned turns) | n/a |
```bash
ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --provider anthropic
OPENAI_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --provider openai
GEMINI_API_KEY=… pnpm --filter @mcp-examples/cli-client start -- --provider gemini
# pin an exact model instead of the resolved latest
ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --provider anthropic --model claude-sonnet-4-5
```
Model ids are deliberately not hardcoded: unless pinned, each provider asks its own models API for the newest mid-tier model, so the example keeps working as vendors ship new ones. The `scripted` provider replays a fixed conversation — it is what CI uses (see [testing](#how-this-example-is-tested)), and what you get when no key is set.
## Pair it with todos-server (two terminals)
The full demo is the reference pair talking over HTTP:
```bash
# Terminal A — serve the reference server over Streamable HTTP (port 3000)
pnpm --filter @mcp-examples/todos-server start:http
# Terminal B — connect the host to it (add a provider key for a real model)
ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp --provider anthropic
```
The status line shows what was negotiated — `connected to "todos" (2026-07-28, 8 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`).
A tour that touches everything, in one sitting:
```text
brainstorm some tasks ← elicitation form (theme + how many) + approval-gated sampling
prioritize my open tasks ← sampling: you approve the request before it runs
/todos:plan-my-day focus=ops ← an MCP prompt as a slash command (tab-completes)
@todos:todos://board what's next? ← attach a resource as context
/watch @todos:todos://board ← subscribe: a note appears whenever the board changes
do all my tasks ← per-task progress + log notifications stream live
(Ctrl-C mid-run) ← cancellation: the tool stops early, the model is told
clear my completed tasks ← elicitation-confirmed bulk delete
/help /servers /tools /resources /prompts /roots
```
Tab completes slash commands, prompt names, `@server:uri` mentions, and prompt argument values (the latter through MCP `completion/complete`).
## Every feature, and where to see it
| MCP feature | Where you see it | What the host does |
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tools | just chat | aggregates every server's tools under `mcp__<server>__<tool>`, hands them to the model, executes the calls it makes, feeds results (including `isError`) back, repeats |
| Resources | `@todos:todos://board` in a message, `/resources` | `resources/read` → injected as a provenance-labelled context block; `list_changed` keeps the cached list fresh |
| Subscriptions | `/watch @todos:todos://board` | `resources/subscribe` on 2025-era connections, a `subscriptions/listen` resource filter on 2026-07-28; updates render as notes as the board changes |
| Prompts | `/todos:plan-my-day focus=ops`, `/prompts` | `prompts/get` seeds the conversation with the returned messages, keeping their roles |
| Completions | tab on prompt arguments | `completion/complete` against the server's `completable()` argument values |
| Sampling | the `prioritize` / `brainstorm_tasks` tools | the server borrows the host's model: the request is shown to you for approval, then routed through the same `LLMProvider` that drives the chat |
| Elicitation | `clear_done`, `brainstorm_tasks` | a terminal form generated from the requested schema; accept / decline / cancel are all honoured |
| Roots | `--root`, `/roots`, `/root add <path>` | workspace roots served via `roots/list`; on change, `roots/list_changed` on 2025-era connections (2026-07-28 removed the notification — servers re-request roots) |
| Logging & progress | the status lines, "do all my tasks" | `notifications/message` and per-call progress rendered as the work happens |
| Cancellation | Ctrl-C while a tool call is running | the host aborts the call's `RequestOptions.signal` (the SDK sends `notifications/cancelled`); todos-server checks `ctx.mcpReq.signal` and stops early |
| Auth | HTTP servers in your config | static headers from the config, or a full browser OAuth flow when a server answers 401 |
## Connect it to your own servers
For a one-off connection, skip the config file and pass the server directly:
```sh
pnpm --filter @mcp-examples/cli-client start -- --server https://your-server.example.com/mcp
```
`--server` (repeatable) connects to exactly the targets you list: http(s) URLs over Streamable HTTP — the OAuth flow starts automatically if the server answers 401 — and anything else is spawned as a stdio command line.
For a persistent setup, copy `config.example.json` to `config.json` (or pass `--config <path>`) and list any MCP servers — the same shape most hosts read:
```jsonc
{
"mcpServers": {
"todos": { "command": "npx", "args": ["-y", "tsx", "/absolute/path/to/examples/todos-server/server.ts"] },
"docs": { "url": "https://example.com/mcp" },
"internal": { "url": "https://mcp.internal.example.com/mcp", "headers": { "Authorization": "Bearer ${INTERNAL_TOKEN}" } }
}
}
```
- `command`/`args` entries are spawned as child processes (stdio). They get a minimal environment plus whatever the entry's `env` lists — never the host's full environment. Relative paths resolve from wherever you run the CLI, so prefer absolute paths when in doubt.
- `url` entries connect over Streamable HTTP. `${VAR}` in `headers`/`env` values is read from the host's environment, so secrets stay out of the file.
- An HTTP server without configured headers that answers 401 triggers the OAuth flow: cli-client asks before opening your browser, runs authorization-code + PKCE against the server's authorization server, and verifies the callback `state`. Tokens live in memory for the session. (Try it against the [`oauth/`](../oauth/README.md) example server; `--callback-port <n>` pins the loopback callback port when you need to forward it over SSH.)
## All flags
```text
--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 todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with a 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)
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
-h, --help show usage
```
## How this example is tested
`client.ts` is the CI entry: it replays a scripted conversation (`script/session.ts`) against todos-server with the `ScriptedProvider`, asserting at each step that the loop, namespacing, resource attachment, prompt-role handling, sampling approval, the multi-round elicitation + signed-`requestState` flow, completions, cancellation, progress, and logging actually round-tripped — over stdio and Streamable HTTP, on both protocol eras (the progress/logging/subscription assertions run on the stdio legs, where delivery timing is deterministic). `pnpm run:examples` runs it in CI; `pnpm --filter @mcp-examples/cli-client test` runs the unit tests for the provider mappings, routing, config parsing, form handling, and the OAuth helpers.
On the legacy-era HTTP leg the sampling/elicitation steps are skipped: push-style server→client requests need a session, and todos-server runs `createMcpHandler`'s default stateless posture there (see [`sampling/`](../sampling/README.md) for the same caveat).
## Layout
```text
cli.ts interactive entry (readline chat)
client.ts CI entry (scripted conversation, self-verifying)
server.ts thin shim that runs ../todos-server/server.ts (so the example runner can spawn the pair)
host/ the host itself: connections, tool routing, resources, prompts,
sampling/elicitation/roots handlers, OAuth, config, terminal UI
providers/ the LLMProvider seam + one complete mapping per provider
script/ the scripted conversation CI replays
test/ unit tests
```
Unlike the single-feature stories, the SDK `Client`/transport construction here lives in `host/host.ts` rather than inline in the entry files — the host wiring is what this example documents.
## Design notes
Choices in here that are worth understanding before copying:
- **The provider seam is deliberately example-local.** The SDK stays a protocol library; a host's message shapes belong to the host. The seam earns its keep twice: MCP `Tool.inputSchema` is already JSON Schema and passes to each vendor API untouched, and the same `generate()` answers both the chat loop and servers' sampling requests — one model integration, two consumers.
- **Tool results go back to the model verbatim, including failures.** An `isError` result is fed back as a tool message rather than thrown, so the model can read the error and try something else. A round cap bounds a model that keeps calling tools forever.
- **Server-controlled text is untrusted display input.** ANSI/control escapes are stripped on every render path; attached resources are size-capped and wrapped in provenance labels so the model knows what it is reading and where it came from, and is told not to re-fetch it.
- **Prompts keep their roles.** `prompts/get` messages seed the conversation as separate user/assistant turns instead of being flattened into one block — that is what the shape is for.
- **Approvals are explicit and fail closed.** Sampling shows the full request (not a preview) and caps `maxTokens` regardless of what the server asked. Browser-opening — OAuth and URL-mode elicitation alike — requires `https:` (or loopback) and user consent. The OAuth callback's `state` is verified by the host, and a missing or mismatched value aborts the flow.
- **Tool execution is not gated here** because an interactive user watches every call and holds Ctrl-C. An unattended host must add a consent policy — confirm destructive or side-effecting calls, or keep a per-server allowlist — and should treat tool annotations (`readOnlyHint`, `destructiveHint`) as UX hints, never as a security boundary.
- **Spawned servers get a minimal environment**: the config entry's `env` plus defaults, never the host's full environment, so provider API keys cannot leak into child processes.
Not goals of this example: it is not an agent framework (no plugins, sub-agents, or planning), there is no streaming output, no conversation persistence, and the providers make exactly one `generate()` call per turn.

154
examples/cli-client/cli.ts Normal file
View file

@ -0,0 +1,154 @@
#!/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
--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' },
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(' ')}`);
}
const host = new McpHost({
ui,
provider,
roots: values.root ?? [process.cwd()],
legacy: values.legacy ?? false,
oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined
});
hostRef.current = host;
try {
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();
}

View file

@ -0,0 +1,48 @@
/**
* The CI entry point: replays the scripted conversation in script/session.ts against the
* sibling todos-server with the keyless ScriptedProvider, asserting at every step. This is
* what `pnpm run:examples` executes over the stdio and Streamable HTTP legs; run it yourself
* with `pnpm --filter @mcp-examples/cli-client client`. The interactive entry for humans is
* cli.ts (`pnpm --filter @mcp-examples/cli-client start`).
*/
import { parseExampleArgs, siblingPath } from '@mcp-examples/shared';
import type { CliClientConfig } from './host/config';
import { McpHost } from './host/host';
import { createSession, handleUserInput } from './host/loop';
import { ScriptedProvider } from './providers/scripted';
import { ScriptedUI } from './script/scriptedUi';
import { buildScriptedSession } from './script/session';
const { transport, url, era } = parseExampleArgs();
// Push-style server→client requests (2025-era sampling/elicitation) have no return path on a
// stateless legacy HTTP deployment, so that leg skips the prioritize/clear_done steps and
// still exercises tools, resources-as-context, and prompts.
const interactive = !(era === 'legacy' && transport === 'http');
const session = buildScriptedSession({ interactive });
const ui = new ScriptedUI({ confirmAnswers: session.confirmAnswers, askAnswers: session.askAnswers });
const provider = new ScriptedProvider(session.turns);
const host = new McpHost({ ui, provider, roots: [process.cwd()], legacy: era === 'legacy' });
const config: CliClientConfig =
transport === 'stdio'
? { mcpServers: { todos: { command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, '../todos-server/server.ts')] } } }
: { mcpServers: { todos: { url } } };
await host.connect(config);
const chat = createSession(host, provider, ui);
for (const [index, input] of session.inputs.entries()) {
session.beforeInput?.[index]?.(ui);
await handleUserInput(chat, input);
}
// Give debounced list-change refreshes (the SDK coalesces them for ~300 ms) a moment to land.
await new Promise(resolve => setTimeout(resolve, 750));
await session.verify({ ui, provider, host, era, transport });
await host.close();
console.log('cli-client e2e: all checks passed');

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"todos": {
"command": "npx",
"args": ["-y", "tsx", "../todos-server/server.ts"]
}
}
}

View file

@ -0,0 +1,213 @@
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type {
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthClientProvider,
OAuthDiscoveryState,
OAuthTokens
} from '@modelcontextprotocol/client';
import open from 'open';
import type { HostUI } from './ui';
/**
* In-memory OAuth provider for an interactive CLI host. Tokens live for the lifetime of the
* process; a real host would persist them in the platform keychain. The SDK drives the whole
* authorization-code + PKCE flow — the host only supplies storage, the redirect hook, and the
* `state` value it must verify when the callback comes back.
*/
export class CliOAuthClientProvider implements OAuthClientProvider {
private clientInfo?: OAuthClientInformationMixed;
private oauthTokens?: OAuthTokens;
private verifier?: string;
private discovery?: OAuthDiscoveryState;
private currentState?: string;
/** The authorization URL the SDK asked us to open (deferred until the user approves). */
pendingAuthorizationUrl?: URL;
constructor(
readonly redirectUrl: string,
readonly clientMetadata: OAuthClientMetadata
) {}
state(): string {
this.currentState ??= crypto.randomUUID();
return this.currentState;
}
/** The SDK never checks `state` itself — the host must compare this against the callback. */
get expectedState(): string | undefined {
return this.currentState;
}
clientInformation(): OAuthClientInformationMixed | undefined {
return this.clientInfo;
}
saveClientInformation(clientInformation: OAuthClientInformationMixed): void {
this.clientInfo = clientInformation;
}
tokens(): OAuthTokens | undefined {
return this.oauthTokens;
}
saveTokens(tokens: OAuthTokens): void {
this.oauthTokens = tokens;
}
redirectToAuthorization(authorizationUrl: URL): void {
// connect() is already in flight here, so just remember the URL; the host opens the
// browser only after the user has agreed to authorize this server.
this.pendingAuthorizationUrl = authorizationUrl;
}
saveCodeVerifier(codeVerifier: string): void {
this.verifier = codeVerifier;
}
codeVerifier(): string {
if (!this.verifier) throw new Error('No code verifier saved');
return this.verifier;
}
saveDiscoveryState(state: OAuthDiscoveryState): void {
this.discovery = state;
}
discoveryState(): OAuthDiscoveryState | undefined {
return this.discovery;
}
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void {
if (scope === 'all' || scope === 'client') this.clientInfo = undefined;
if (scope === 'all' || scope === 'tokens') this.oauthTokens = undefined;
if (scope === 'all' || scope === 'verifier') this.verifier = undefined;
if (scope === 'all' || scope === 'discovery') this.discovery = undefined;
}
}
export function createOAuthProvider(serverName: string, callbackPort: number): CliOAuthClientProvider {
const callbackUrl = `http://127.0.0.1:${callbackPort}/callback`;
return new CliOAuthClientProvider(callbackUrl, {
client_name: `cli-client (${serverName})`,
redirect_uris: [callbackUrl],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'native',
token_endpoint_auth_method: 'none'
});
}
/** Start a loopback HTTP server on 127.0.0.1 and resolve with the OAuth callback's query parameters. */
export function waitForOAuthCallback(port: number): Promise<URLSearchParams> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const requestUrl = new URL(req.url ?? '/', 'http://localhost');
if (requestUrl.pathname !== '/callback') {
res.writeHead(404).end();
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Authorization received</h1><p>You can close this window and return to cli-client.</p></body></html>');
resolve(requestUrl.searchParams);
setTimeout(() => server.close(), 1000);
});
server.on('error', reject);
server.listen(port, '127.0.0.1');
});
}
/** A free loopback port for the OAuth callback, picked by the OS. */
export async function findCallbackPort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createServer();
probe.on('error', reject);
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address() as AddressInfo;
probe.close(() => resolve(port));
});
});
}
function isLoopbackHost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
}
/**
* True when a server-supplied URL is safe to hand to the system browser: `https:`, or `http:`
* on a loopback host. Everything else (`file:`, `javascript:`, plain http to a remote host)
* fails closed. Shared by the OAuth flow and URL-mode elicitation.
*/
export function isSafeBrowserUrl(url: URL): boolean {
return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHost(url.hostname));
}
/**
* Complete an interactive OAuth flow after `connect()` failed with `UnauthorizedError`:
* confirm with the user, open the system browser, wait for the loopback callback, verify
* `state`, and let the transport exchange the code (`finishAuth`). The caller then reconnects
* on a fresh transport with the same provider.
*/
export async function completeAuthorizationWithBrowser(options: {
serverName: string;
ui: HostUI;
provider: CliOAuthClientProvider;
callbackPort: number;
finishAuth: (callbackParams: URLSearchParams) => Promise<void>;
/** Overridable so tests (or hosts with their own browser handling) don't shell out. */
openUrl?: (url: string) => Promise<void>;
}): Promise<boolean> {
const { serverName, ui, provider, callbackPort, finishAuth } = options;
const openUrl = options.openUrl ?? (async (url: string) => void (await open(url)));
const authorizationUrl = provider.pendingAuthorizationUrl;
if (!authorizationUrl) return false;
// The authorization endpoint comes from server-controlled discovery metadata — never hand
// a non-https (or non-loopback) URL to the browser, and show the user where they're going.
if (!isSafeBrowserUrl(authorizationUrl)) {
ui.status(`skipping "${serverName}" — refusing to open a non-https authorization URL`);
return false;
}
ui.attention(`[authorization]\nServer "${serverName}" requires you to sign in via your browser.`);
const approved = await ui.confirm('Open your browser to sign in?');
if (!approved) {
ui.status(`skipping "${serverName}" — authorization declined`);
return false;
}
const callback = waitForOAuthCallback(callbackPort);
// Attach a handler immediately so a listen failure can't become an unhandled rejection
// while the browser-open is still in flight.
callback.catch(() => {});
ui.status('opening your browser to sign in…');
try {
await openUrl(authorizationUrl.toString());
} catch {
// Show the URL through the interactive prompt rather than a log line: the flow now
// waits for the user instead of racing them, and the URL is displayed, not logged.
await ui.ask(
`Could not open a browser automatically. Open this URL in your browser, then press Enter\n\n ${authorizationUrl.toString()}\n\nReady?`
);
}
let params: URLSearchParams;
try {
params = await callback;
} catch (error) {
ui.status(`authorization for "${serverName}" failed: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
if (params.get('error')) {
// Do not echo error_description — it is attacker-controllable in mix-up attacks.
ui.status(`authorization for "${serverName}" failed`);
return false;
}
// Fail closed: no recorded state (or a mismatch) means the callback cannot be trusted.
const expectedState = provider.expectedState;
if (!expectedState || params.get('state') !== expectedState) {
ui.status(`authorization for "${serverName}" rejected: state mismatch`);
return false;
}
await finishAuth(params);
return true;
}

View file

@ -0,0 +1,115 @@
import { readFile } from 'node:fs/promises';
import { siblingPath } from '@mcp-examples/shared';
import * as z from 'zod/v4';
/**
* The standard `mcpServers` config shape most MCP hosts read: stdio servers are spawned
* from `command`/`args`, remote servers are
* reached via `url`. Anything you list here is code/infrastructure you trust — adding a
* server means trusting it with whatever the model sends it.
*/
const stdioServerSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
/** Extra environment for the spawned server. Children do NOT inherit the host's env. */
env: z.record(z.string(), z.string()).optional(),
cwd: z.string().optional()
});
const httpServerSchema = z.object({
url: z.string(),
/** Static headers (e.g. `Authorization: Bearer ${MY_TOKEN}`); `${VAR}` reads from the host env. */
headers: z.record(z.string(), z.string()).optional()
});
const configSchema = z.object({
mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema]))
});
export type StdioServerConfig = z.infer<typeof stdioServerSchema>;
export type HttpServerConfig = z.infer<typeof httpServerSchema>;
export type ServerConfig = StdioServerConfig | HttpServerConfig;
export type CliClientConfig = z.infer<typeof configSchema>;
export function isHttpServer(config: ServerConfig): config is HttpServerConfig {
return 'url' in config;
}
/**
* Replace `${VAR}` references with values from the environment, so secrets live in env vars
* rather than in the config file. Unknown variables resolve to ''.
*/
export function interpolateEnv(value: string, env: Record<string, string | undefined> = process.env): string {
return value.replaceAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name: string) => env[name] ?? '');
}
export function parseConfig(json: string, env: Record<string, string | undefined> = process.env): CliClientConfig {
const parsed = configSchema.parse(JSON.parse(json));
for (const entry of Object.values(parsed.mcpServers)) {
if (isHttpServer(entry)) {
entry.url = interpolateEnv(entry.url, env);
if (entry.headers) {
for (const [header, value] of Object.entries(entry.headers)) {
entry.headers[header] = interpolateEnv(value, env);
}
}
} else if (entry.env) {
for (const [name, value] of Object.entries(entry.env)) {
entry.env[name] = interpolateEnv(value, env);
}
}
}
return parsed;
}
export async function readConfigFile(path: string): Promise<CliClientConfig> {
return parseConfig(await readFile(path, 'utf8'));
}
/** The zero-setup default: spawn the sibling todos-server over stdio. */
export function todosServerConfig(): CliClientConfig {
return {
mcpServers: {
todos: {
command: 'npx',
args: ['-y', 'tsx', siblingPath(import.meta.url, '../../todos-server/server.ts')]
}
}
};
}
/** Derive a friendly server name from an ad-hoc `--server` URL (mcp.linear.app → "linear"). */
function serverNameFromUrl(url: URL): string {
const generic = new Set(['mcp', 'www', 'api', 'app', 'dev', 'com', 'io', 'net', 'org', 'ai', 'run', 'co']);
const meaningful = url.hostname.split('.').find(label => !generic.has(label));
return meaningful ?? url.hostname;
}
/** Derive a friendly server name from an ad-hoc `--server` command line ("npx -y tsx server.ts" → "server"). */
function serverNameFromCommand(tokens: string[]): string {
const last = tokens.at(-1) ?? 'server';
const base = last.split(/[/\\]/).pop() ?? last;
return base.replace(/\.[A-Za-z]+$/, '') || 'server';
}
/**
* Build a config from ad-hoc `--server` arguments: http(s) URLs become Streamable HTTP entries
* (the OAuth flow starts on demand if the server answers 401), anything else is treated as a
* stdio command line to spawn.
*/
export function configFromTargets(targets: string[]): CliClientConfig {
const mcpServers: Record<string, ServerConfig> = {};
const claim = (name: string, index: number): string => (mcpServers[name] === undefined ? name : `${name}_${index + 1}`);
for (const [index, target] of targets.entries()) {
if (/^https?:\/\//i.test(target)) {
mcpServers[claim(serverNameFromUrl(new URL(target)), index)] = { url: target };
} else {
const [command, ...args] = target.split(/\s+/).filter(token => token.length > 0);
if (!command) throw new Error('--server got an empty target');
mcpServers[claim(serverNameFromCommand([command, ...args]), index)] = { command, args };
}
}
if (Object.keys(mcpServers).length === 0) throw new Error('--server needs at least one URL or command line');
return { mcpServers };
}

View file

@ -0,0 +1,98 @@
import type { CallToolResult, ContentBlock, ReadResourceResult } from '@modelcontextprotocol/client';
import type { ContentPart } from '../providers/provider';
import { partsToText } from '../providers/provider';
/**
* How much server-provided text the model gets to see is a host policy, not an SDK or
* protocol concern. cli-client applies one cap to everything it injects (tool results and
* attached resources alike).
*/
export const MAX_INJECTED_CHARS = 50_000;
/**
* Strip terminal escape sequences and stray control characters from server-provided text
* before rendering it: CSI sequences (colors, cursor movement), OSC sequences (window titles,
* hyperlinks), other ESC-introduced sequences, and any remaining C0 controls except tab,
* newline, and carriage return. Servers are not trusted to write to the user's terminal.
*/
const TERMINAL_ESCAPES =
// eslint-disable-next-line no-control-regex
/(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)?|\u001B[@-Z\\^_]|[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
export function stripAnsi(text: string): string {
return text.replaceAll(TERMINAL_ESCAPES, '');
}
export function truncate(text: string, limit: number = MAX_INJECTED_CHARS): string {
if (text.length <= limit) return text;
return `${text.slice(0, limit)}\n[truncated ${text.length - limit} characters — cli-client caps injected content at ${limit} characters]`;
}
/**
* Convert one MCP content block into provider content parts.
*
* Text and images pass through; audio, resource links, and binary embedded resources are
* reduced to placeholders the model can reason about. This is the narrowing every host
* writes — note there are five block types, not just text.
*/
export function contentBlockToParts(block: ContentBlock): ContentPart[] {
switch (block.type) {
case 'text': {
return [{ type: 'text', text: truncate(block.text) }];
}
case 'image': {
return [{ type: 'image', mimeType: block.mimeType, data: block.data }];
}
case 'audio': {
return [{ type: 'text', text: `[audio content: ${block.mimeType}]` }];
}
case 'resource_link': {
return [{ type: 'text', text: `[linked resource: ${block.uri}${block.description ? ` — ${block.description}` : ''}]` }];
}
case 'resource': {
if ('text' in block.resource) {
return [{ type: 'text', text: truncate(`[embedded resource ${block.resource.uri}]\n${block.resource.text}`) }];
}
return [{ type: 'text', text: `[binary resource ${block.resource.uri}: ${block.resource.mimeType ?? 'unknown type'}]` }];
}
default: {
return [{ type: 'text', text: '[unsupported content block]' }];
}
}
}
/** Convert MCP tool-result content into provider content parts (empty results get a placeholder). */
export function toolResultToParts(result: CallToolResult): ContentPart[] {
const parts = result.content.flatMap(block => contentBlockToParts(block));
if (parts.length === 0) {
parts.push({ type: 'text', text: '(tool returned no content)' });
}
return parts;
}
/**
* Render a read resource as a context block for the conversation, with explicit provenance
* (which server, which URI) and an instruction not to re-fetch — so the model can cite where
* the content came from and does not burn a tool round re-reading it.
*/
export function resourceToContextText(serverName: string, uri: string, result: ReadResourceResult): string {
const rendered = result.contents
.map(item =>
'text' in item
? item.text
: `[binary content ${item.mimeType ?? 'unknown type'}, ${Math.ceil((item.blob.length * 3) / 4)} bytes]`
)
.join('\n');
return [
`<attached-resource server="${serverName}" uri="${uri}">`,
truncate(rendered),
'</attached-resource>',
'The user attached this MCP resource as context. Use it to answer; do not re-read it unless told it changed.'
].join('\n');
}
/** One-line rendering of content parts for the terminal (status output, not the model). */
export function partsToDisplayText(parts: ContentPart[]): string {
return stripAnsi(partsToText(parts).replaceAll('\n', ' ')).trim();
}

View file

@ -0,0 +1,542 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type {
CallToolResult,
CreateMessageRequest,
ElicitResult,
GetPromptResult,
McpSubscription,
Prompt,
Resource,
ResourceTemplateType,
Tool
} from '@modelcontextprotocol/client';
import {
Client,
LOG_LEVEL_META_KEY,
ProtocolError,
SdkError,
StreamableHTTPClientTransport,
UnauthorizedError
} from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import type { ChatMessage, ContentPart, GenerateResult, LLMProvider, ToolCall, ToolDefinition } from '../providers/provider';
import { isRecord } from '../providers/provider';
import { completeAuthorizationWithBrowser, createOAuthProvider, findCallbackPort, isSafeBrowserUrl } from './auth';
import type { CliClientConfig, ServerConfig } from './config';
import { isHttpServer } from './config';
import { contentBlockToParts, resourceToContextText, toolResultToParts } from './content';
import { namespaceTool, routeNamespacedTool, sanitizeServerName } from './naming';
import type { HostUI } from './ui';
import { collectFormInput } from './ui';
const CLIENT_INFO = { name: 'cli-client', version: '0.1.0' };
/** Cap what a server can spend through the sampling handler, regardless of what it asks for. */
const SAMPLING_MAX_TOKENS_CAP = 2048;
export interface ConnectedServer {
name: string;
/** Sanitized name used in tool namespacing and slash commands. */
key: string;
client: Client;
era: 'modern' | 'legacy';
/** The protocol revision actually negotiated for this connection (e.g. "2026-07-28"). */
protocolVersion: string;
httpTransport?: StreamableHTTPClientTransport;
instructions?: string;
tools: Tool[];
resources: Resource[];
resourceTemplates: ResourceTemplateType[];
prompts: Prompt[];
}
export interface McpHostOptions {
ui: HostUI;
/** The same provider that drives the chat loop also answers sampling requests. */
provider: LLMProvider;
/** Workspace roots exposed to servers via `roots/list` (absolute or cwd-relative paths). */
roots?: string[];
/** Use the 2025 `initialize` handshake instead of probing for 2026-07-28. */
legacy?: boolean;
/** Fixed loopback port for the OAuth callback (default: an OS-assigned free port). Useful over SSH port-forwarding. */
oauthCallbackPort?: number;
}
function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined {
if (error instanceof UnauthorizedError) return error;
// Under versionNegotiation 'auto', a connect-time 401 surfaces as
// SdkError(EraNegotiationFailed) with the UnauthorizedError in error.data.cause.
if (error instanceof SdkError && isRecord(error.data) && error.data.cause instanceof UnauthorizedError) {
return error.data.cause;
}
return undefined;
}
function samplingContentToParts(content: CreateMessageRequest['params']['messages'][number]['content']): ContentPart[] {
const blocks = Array.isArray(content) ? content : [content];
const parts: ContentPart[] = [];
for (const block of blocks) {
if (block.type === 'text') parts.push({ type: 'text', text: block.text });
else if (block.type === 'image') parts.push({ type: 'image', mimeType: block.mimeType, data: block.data });
else parts.push({ type: 'text', text: `[${block.type} content]` });
}
return parts;
}
/**
* One MCP client per configured server, plus everything a host owes the servers it connects
* to: tool aggregation and routing, resources as context, prompts, and the handlers for
* server-initiated requests (sampling, elicitation, roots), logging, and progress.
*/
export class McpHost {
private readonly ui: HostUI;
private readonly provider: LLMProvider;
private readonly legacy: boolean;
private roots: string[];
private readonly watches: McpSubscription[] = [];
private readonly oauthCallbackPort?: number;
readonly servers = new Map<string, ConnectedServer>();
constructor(options: McpHostOptions) {
this.ui = options.ui;
this.provider = options.provider;
this.legacy = options.legacy ?? false;
this.oauthCallbackPort = options.oauthCallbackPort;
this.roots = (options.roots ?? [process.cwd()]).map(root => path.resolve(root));
}
async connect(config: CliClientConfig): Promise<void> {
for (const [name, entry] of Object.entries(config.mcpServers)) {
try {
const server = await this.connectServer(name, entry);
if (!server) continue;
// Sanitized keys can collide ("my server" vs "my_server") — keep them unique so
// namespaced tool calls always route to exactly one server.
const usedKeys = new Set([...this.servers.values()].map(existing => existing.key));
for (let suffix = 2; usedKeys.has(server.key); suffix++) {
server.key = `${sanitizeServerName(name)}_${suffix}`;
}
this.servers.set(name, server);
this.ui.status(
`connected to "${name}" (${server.protocolVersion}, ${server.tools.length} tools, ${server.resources.length + server.resourceTemplates.length} resources, ${server.prompts.length} prompts)`
);
} catch (error) {
this.ui.status(`failed to connect to "${name}": ${error instanceof Error ? error.message : String(error)}`);
}
}
if (this.servers.size === 0) {
throw new Error('No MCP servers connected — check the config file');
}
}
/** Aggregated, namespaced tool definitions for the model. */
toolDefinitions(): ToolDefinition[] {
const definitions: ToolDefinition[] = [];
for (const server of this.servers.values()) {
for (const tool of server.tools) {
definitions.push({
name: namespaceTool(server.key, tool.name),
description: tool.description,
inputSchema: tool.inputSchema
});
}
}
return definitions;
}
/** Server instructions folded into the system prompt — that is what they exist for. */
systemInstructions(): string {
const sections = [...this.servers.values()]
.filter(server => server.instructions)
.map(server => `Instructions from the "${server.name}" server:\n${server.instructions}`);
return sections.join('\n\n');
}
/** Execute a model-issued tool call against the server that owns it. */
async executeToolCall(call: ToolCall, options?: { signal?: AbortSignal }): Promise<{ parts: ContentPart[]; isError: boolean }> {
const route = routeNamespacedTool(
call.name,
[...this.servers.values()].map(server => server.key)
);
const server = route && [...this.servers.values()].find(candidate => candidate.key === route.serverKey);
if (!route || !server) {
return { parts: [{ type: 'text', text: `Unknown tool: ${call.name}` }], isError: true };
}
try {
const result: CallToolResult = await server.client.callTool(
{
name: route.toolName,
arguments: call.arguments,
// On 2026-07-28 connections servers only emit log notifications for requests
// that opt in via this _meta key; on 2025 the setLoggingLevel call covers it.
...(server.era === 'modern' ? { _meta: { [LOG_LEVEL_META_KEY]: 'info' } } : {})
},
{
// Aborting this signal cancels the call: the SDK sends notifications/cancelled
// and the server can stop work via its own request signal.
signal: options?.signal,
onprogress: progress => {
const total = progress.total === undefined ? '' : `/${progress.total}`;
this.ui.status(`${call.name}: ${progress.message ?? 'working'} (${progress.progress}${total})`);
},
resetTimeoutOnProgress: true
}
);
return { parts: toolResultToParts(result), isError: result.isError === true };
} catch (error) {
if (options?.signal?.aborted) {
return { parts: [{ type: 'text', text: 'Tool call cancelled by the user.' }], isError: true };
}
// A thrown ProtocolError/SdkError (unknown tool, timeout, lost connection) is not the
// same thing as a tool-level isError result, but the model should see both as failures.
const reason = error instanceof Error ? error.message : String(error);
return { parts: [{ type: 'text', text: `Tool call failed: ${reason}` }], isError: true };
}
}
/** Resolve a `server:uri` reference (the part after the `@`) to the owning server and the resource URI. */
private resolveResourceReference(reference: string): { server: ConnectedServer; uri: string } {
const separator = reference.indexOf(':');
if (separator === -1) throw new Error(`Resource references look like @server:uri — got "@${reference}"`);
const serverName = reference.slice(0, separator);
const uri = reference.slice(separator + 1);
// Accept the configured name or its sanitized key (the form used in tool names and /commands).
const server = this.servers.get(serverName) ?? [...this.servers.values()].find(candidate => candidate.key === serverName);
if (!server) throw new Error(`Unknown server "${serverName}" in @${reference}`);
return { server, uri };
}
/** Resolve an `@server:uri` mention into a provenance-labelled context block. */
async attachResource(reference: string): Promise<string> {
const { server, uri } = this.resolveResourceReference(reference);
const result = await server.client.readResource({ uri });
return resourceToContextText(server.name, uri, result);
}
/**
* Watch a resource for change notifications. On 2025-era connections this is the
* `resources/subscribe` request; on 2026-07-28 connections per-resource subscriptions ride
* a `subscriptions/listen` stream instead. Updates arrive through the same
* `notifications/resources/updated` handler either way.
*/
async watchResource(reference: string): Promise<void> {
const { server, uri } = this.resolveResourceReference(reference);
if (server.era === 'legacy') {
await server.client.subscribeResource({ uri });
return;
}
const subscription = await server.client.listen({ resourceSubscriptions: [uri] });
// The server acknowledges which parts of the filter it will honour — don't pretend to
// watch a resource the server will never report on.
if (!subscription.honoredFilter.resourceSubscriptions?.includes(uri)) {
await subscription.close().catch(() => {});
throw new Error(`server "${server.name}" does not support resource subscriptions`);
}
this.watches.push(subscription);
}
listResources(): Array<{ server: string; resource: Resource }> {
return [...this.servers.values()].flatMap(server => server.resources.map(resource => ({ server: server.name, resource })));
}
listPrompts(): Array<{ server: string; prompt: Prompt }> {
return [...this.servers.values()].flatMap(server => server.prompts.map(prompt => ({ server: server.name, prompt })));
}
findPrompt(serverName: string, promptName: string): { server: ConnectedServer; prompt: Prompt } | undefined {
const server = this.servers.get(serverName) ?? [...this.servers.values()].find(candidate => candidate.key === serverName);
const prompt = server?.prompts.find(candidate => candidate.name === promptName);
return server && prompt ? { server, prompt } : undefined;
}
/** Argument-value suggestions for a prompt via MCP `completion/complete` (powers tab completion). */
async completePromptArgument(serverName: string, promptName: string, argumentName: string, value: string): Promise<string[]> {
const server = this.servers.get(serverName);
if (!server?.client.getServerCapabilities()?.completions) return [];
try {
const result = await server.client.complete({
ref: { type: 'ref/prompt', name: promptName },
argument: { name: argumentName, value }
});
return result.completion.values;
} catch {
return [];
}
}
/** `prompts/get`, with the returned message roles preserved as separate conversation turns. */
async getPromptMessages(serverName: string, promptName: string, args: Record<string, string>): Promise<ChatMessage[]> {
const found = this.findPrompt(serverName, promptName);
if (!found) throw new Error(`Unknown prompt ${serverName}:${promptName}`);
const result: GetPromptResult = await found.server.client.getPrompt({ name: promptName, arguments: args });
return result.messages.map(message => ({
role: message.role,
content: contentBlockToParts(message.content)
}));
}
listRoots(): string[] {
return [...this.roots];
}
/** Add a workspace root and tell connected (legacy-era) servers the list changed. */
async addRoot(directory: string): Promise<void> {
this.roots.push(path.resolve(directory));
for (const server of this.servers.values()) {
// roots/list_changed is a 2025-era notification; on 2026-07-28 connections the
// method is gone and servers re-request roots when they need them.
if (server.era === 'legacy') {
await server.client.sendRootsListChanged().catch(() => {});
}
}
}
async close(): Promise<void> {
for (const watch of this.watches) {
await watch.close().catch(() => {});
}
for (const server of this.servers.values()) {
if (server.httpTransport) {
await server.httpTransport.terminateSession().catch(() => {});
}
await server.client.close().catch(() => {});
}
this.servers.clear();
}
private buildClient(name: string): Client {
const client = new Client(CLIENT_INFO, {
versionNegotiation: { mode: this.legacy ? 'legacy' : 'auto' },
capabilities: {
// Both elicitation modes are declared because the handler below implements both.
elicitation: { form: {}, url: {} },
sampling: {},
roots: { listChanged: true }
},
listChanged: {
tools: {
onChanged: (error, tools) => {
const server = this.servers.get(name);
if (error || !server || !tools) return;
server.tools = tools;
this.ui.status(`tool list changed on "${name}" (${tools.length} tools)`);
}
},
resources: {
onChanged: (error, resources) => {
const server = this.servers.get(name);
if (error || !server || !resources) return;
server.resources = resources;
this.ui.status(`resource list changed on "${name}" (${resources.length} resources)`);
}
},
prompts: {
onChanged: (error, prompts) => {
const server = this.servers.get(name);
if (error || !server || !prompts) return;
server.prompts = prompts;
this.ui.status(`prompt list changed on "${name}" (${prompts.length} prompts)`);
}
}
}
});
client.onerror = error => this.ui.status(`[${name}] transport error: ${error.message}`);
this.registerSamplingHandler(client, name);
this.registerElicitationHandler(client, name);
this.registerRootsHandler(client);
return client;
}
/**
* Sampling: the server borrows the host's model. The request is shown to the user and
* nothing is sent to the provider until they approve — a server must not be able to spend
* the user's API quota (or exfiltrate conversation context) silently.
*/
private registerSamplingHandler(client: Client, name: string): void {
client.setRequestHandler('sampling/createMessage', async request => {
const params = request.params;
// Show the user the full request they are approving — an abbreviated preview would
// mean approving something they haven't actually seen.
const requestText = [
...(params.systemPrompt ? [`system: ${params.systemPrompt}`] : []),
...params.messages.map(
message =>
`${message.role}: ${samplingContentToParts(message.content)
.map(part => (part.type === 'text' ? part.text : '[image]'))
.join(' ')}`
)
].join('\n');
// Cap the spend regardless of what the server asked for, and approve what is actually sent.
const grantedMaxTokens = Math.min(params.maxTokens, SAMPLING_MAX_TOKENS_CAP);
const capNote = grantedMaxTokens === params.maxTokens ? '' : ` (server asked for ${params.maxTokens})`;
this.ui.attention(
`[sampling request]\nServer "${name}" wants to run an LLM request through your ${this.provider.name} provider (${grantedMaxTokens} max tokens${capNote}):\n\n${requestText}\n`
);
const approved = await this.ui.confirm('Allow?');
if (!approved) {
// The spec's code for a user-rejected sampling request is the application-level -1 —
// not a reserved JSON-RPC code; the request itself was perfectly well-formed.
throw new ProtocolError(-1, 'User rejected sampling request');
}
const stopSpinner = this.ui.spinner();
let result: GenerateResult;
try {
result = await this.provider.generate({
system: params.systemPrompt,
messages: params.messages.map(message => ({ role: message.role, content: samplingContentToParts(message.content) })),
maxTokens: grantedMaxTokens
});
} finally {
stopSpinner();
}
return {
role: 'assistant' as const,
content: { type: 'text' as const, text: result.text },
model: result.model,
stopReason: result.stopReason === 'max_tokens' ? 'maxTokens' : 'endTurn'
};
});
}
/** Elicitation: render the requested form (or URL) in the terminal; errors fail closed to cancel. */
private registerElicitationHandler(client: Client, name: string): void {
client.setRequestHandler('elicitation/create', async (request): Promise<ElicitResult> => {
const params = request.params;
if (params.mode === 'url') {
// Same discipline as the OAuth path: never offer a server-controlled URL to the
// browser unless it is https (or http on loopback) — file:, javascript:, and
// plain-http phishing URLs all fail closed to a decline.
let target: URL | undefined;
try {
target = new URL(params.url);
} catch {
target = undefined;
}
if (!target || !isSafeBrowserUrl(target)) {
this.ui.status(`declined URL elicitation from "${name}" — refusing to open a non-https URL`);
return { action: 'decline' };
}
this.ui.attention(
`[elicitation request]\nServer "${name}" needs you to complete a step in the browser:\n\n${params.url}\n`
);
const opened = await this.ui.confirm('Open the URL and confirm once you are done. Continue?');
return opened ? { action: 'accept' } : { action: 'decline' };
}
this.ui.attention(`[elicitation request]\nServer "${name}" is asking for input:\n\n${params.message}\n`);
return collectFormInput(this.ui, params.requestedSchema);
});
}
private registerRootsHandler(client: Client): void {
client.setRequestHandler('roots/list', () => ({
roots: this.roots.map(root => ({ uri: pathToFileURL(root).href, name: path.basename(root) }))
}));
}
private async connectServer(name: string, entry: ServerConfig): Promise<ConnectedServer | undefined> {
const client = this.buildClient(name);
let httpTransport: StreamableHTTPClientTransport | undefined;
if (isHttpServer(entry)) {
if (entry.headers && Object.keys(entry.headers).length > 0) {
// Static headers (e.g. a bearer token from the environment). No OAuth fallback —
// if the token is wrong the connection error is the more honest signal.
httpTransport = new StreamableHTTPClientTransport(new URL(entry.url), { requestInit: { headers: entry.headers } });
await client.connect(httpTransport);
} else {
const callbackPort = this.oauthCallbackPort ?? (await findCallbackPort());
const oauthProvider = createOAuthProvider(name, callbackPort);
httpTransport = new StreamableHTTPClientTransport(new URL(entry.url), { authProvider: oauthProvider });
try {
await client.connect(httpTransport);
} catch (error) {
if (!unwrapUnauthorized(error)) throw error;
const finishTransport = httpTransport;
const authorized = await completeAuthorizationWithBrowser({
serverName: name,
ui: this.ui,
provider: oauthProvider,
callbackPort,
finishAuth: params => finishTransport.finishAuth(params)
});
if (!authorized) return undefined;
// finishAuth() exchanged the code on the old transport; reconnect on a fresh one.
httpTransport = new StreamableHTTPClientTransport(new URL(entry.url), { authProvider: oauthProvider });
await client.connect(httpTransport);
}
}
} else {
const transport = new StdioClientTransport({
command: entry.command,
args: entry.args,
// The child gets the SDK's minimal default environment plus exactly what the
// config lists — never the host's full environment (API keys stay here).
env: entry.env,
cwd: entry.cwd,
stderr: 'pipe'
});
transport.stderr?.on('data', (chunk: Buffer) => {
const line = String(chunk).trim();
if (line) this.ui.serverLog(name, 'stderr', line);
});
await client.connect(transport);
}
try {
const era = client.getProtocolEra() === 'modern' ? 'modern' : 'legacy';
const capabilities = client.getServerCapabilities();
client.setNotificationHandler('notifications/message', notification => {
const { level, data, logger } = notification.params;
this.ui.serverLog(name, `${logger ? `${logger} ` : ''}${level}`, typeof data === 'string' ? data : JSON.stringify(data));
});
client.setNotificationHandler('notifications/resources/updated', notification => {
this.ui.note(`resource updated: @${name}:${notification.params.uri}`);
});
if (era === 'legacy' && capabilities?.logging) {
await client.setLoggingLevel('info').catch(() => {});
}
// Discovery is gated on the advertised capabilities and degrades per call: a server
// may advertise a capability and still not implement every list method
// (resources/templates/list is the usual gap). One failed listing costs the host an
// empty list and a status line, not the whole connection.
const listOrEmpty = async <T>(label: string, advertised: unknown, list: () => Promise<T[]>): Promise<T[]> => {
if (!advertised) return [];
return list().catch((error: unknown) => {
this.ui.status(`listing ${label} on "${name}" failed: ${error instanceof Error ? error.message : String(error)}`);
return [];
});
};
const [tools, resources, resourceTemplates, prompts] = await Promise.all([
listOrEmpty('tools', capabilities?.tools, () => client.listTools().then(result => result.tools)),
listOrEmpty('resources', capabilities?.resources, () => client.listResources().then(result => result.resources)),
listOrEmpty('resource templates', capabilities?.resources, () =>
client.listResourceTemplates().then(result => result.resourceTemplates)
),
listOrEmpty('prompts', capabilities?.prompts, () => client.listPrompts().then(result => result.prompts))
]);
return {
name,
key: sanitizeServerName(name),
client,
era,
protocolVersion: client.getNegotiatedProtocolVersion() ?? 'unknown',
httpTransport,
instructions: client.getInstructions(),
tools,
resources,
resourceTemplates,
prompts
};
} catch (error) {
// Don't leak a connected client when post-connect setup fails.
await client.close().catch(() => {});
throw error;
}
}
}

View file

@ -0,0 +1,273 @@
import { getDisplayName } from '@modelcontextprotocol/client';
import type { ChatMessage, ContentPart, GenerateResult, LLMProvider } from '../providers/provider';
import { textPart } from '../providers/provider';
import { partsToDisplayText } from './content';
import type { McpHost } from './host';
import type { HostUI } from './ui';
/** A model that keeps calling tools forever is a bug, not a feature — bound the loop. */
export const MAX_TOOL_ROUNDS = 8;
const BASE_SYSTEM_PROMPT =
'You are cli-client, a terminal assistant. You have no built-in tools; every tool available to you comes from a connected MCP server. ' +
'Use them when they help, report tool failures honestly, and keep answers short — this is a terminal. ' +
'When the user greets you or asks what you can do, offer a short tour of what the connected servers provide (their instructions may suggest one).';
export interface ChatSession {
host: McpHost;
provider: LLMProvider;
ui: HostUI;
messages: ChatMessage[];
maxTokens: number;
/** Last model id reported by the provider; announced once so users can see what answered. */
announcedModel?: string;
}
export function createSession(host: McpHost, provider: LLMProvider, ui: HostUI, maxTokens = 1024): ChatSession {
return { host, provider, ui, messages: [], maxTokens };
}
export function buildSystemPrompt(host: McpHost): string {
const instructions = host.systemInstructions();
return instructions ? `${BASE_SYSTEM_PROMPT}\n\n${instructions}` : BASE_SYSTEM_PROMPT;
}
/**
* The loop at the heart of every MCP host:
* ask the model → execute every tool call it issued → feed the results back → repeat until
* the model answers in prose (or the round cap is hit). Tool results go back as `role: 'tool'`
* messages so each provider can encode them natively, and `isError` results still go to the
* model — it is allowed to read the error and try something else.
*/
export async function runModelRounds(session: ChatSession): Promise<void> {
const { host, provider, ui } = session;
// Server instructions and the aggregated tool list are stable within a single user turn.
const system = buildSystemPrompt(host);
const tools = host.toolDefinitions();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const stopSpinner = ui.spinner();
let result: GenerateResult;
try {
result = await provider.generate({
system,
messages: session.messages,
tools,
maxTokens: session.maxTokens
});
} finally {
stopSpinner();
}
session.messages.push({
role: 'assistant',
content: result.text ? [textPart(result.text)] : [],
...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {})
});
if (result.model !== session.announcedModel) {
session.announcedModel = result.model;
ui.status(`model: ${result.model}`);
}
if (result.text) ui.print(result.text);
if (result.toolCalls.length === 0) return;
// cli-client executes tool calls without a confirmation gate because an interactive
// user watches every `→` line and holds Ctrl-C; a host without that live supervision
// must gate execution on user consent (see the guide's security section).
for (const call of result.toolCalls) {
ui.status(`→ ${call.name} ${JSON.stringify(call.arguments)}`);
// Long-running calls stay cancellable: Ctrl-C aborts this call (the SDK sends
// notifications/cancelled) and the failure goes back to the model like any other.
const cancellation = new AbortController();
ui.setCancelHandler(() => {
ui.status(`cancelling ${call.name}…`, 'cancel');
cancellation.abort();
});
let parts: ContentPart[];
let isError: boolean;
try {
({ parts, isError } = await host.executeToolCall(call, { signal: cancellation.signal }));
} finally {
ui.setCancelHandler(undefined);
}
const summary = partsToDisplayText(parts);
ui.status(`${isError ? '✗' : '✓'} ${call.name}: ${summary.length > 200 ? `${summary.slice(0, 200)}…` : summary}`);
session.messages.push({ role: 'tool', toolCallId: call.id, toolName: call.name, content: parts, isError });
}
}
ui.print('(stopped: tool-call round limit reached)');
}
/** Send one user turn (with optional attached-resource context blocks) through the loop. */
export async function runConversationTurn(session: ChatSession, userText: string, attachments: string[] = []): Promise<void> {
const content: ContentPart[] = [...attachments.map(attachment => textPart(attachment)), textPart(userText)];
session.messages.push({ role: 'user', content });
await runModelRounds(session);
}
/** Pull `@server:uri` mentions out of a chat line (server names may contain dots, spaces excepted). */
export function extractMentions(input: string): { text: string; mentions: string[] } {
const mentions = [...input.matchAll(/@([^\s:@]+:\S+)/g)].map(match => match[1]).filter(mention => mention !== undefined);
return { text: input.trim(), mentions };
}
/** Parse `key=value` arguments for a `/server:prompt` command. */
export function parsePromptArgs(rest: string): Record<string, string> {
const args: Record<string, string> = {};
for (const [, key, raw] of rest.matchAll(/([A-Za-z0-9_-]+)=("[^"]*"|\S+)/g)) {
if (key && raw !== undefined) {
args[key] = raw.replaceAll(/^"|"$/g, '');
}
}
return args;
}
const HELP = `cli-client commands:
/help show this help
/servers connected servers and what they offer
/tools every (namespaced) tool the model can call
/resources resources you can attach with @server:uri
/prompts prompts you can run as /server:prompt-name [key=value …]
/roots workspace roots exposed to servers
/root add <path> add a workspace root (sends roots/list_changed)
/watch @server:uri get a note whenever that resource changes
/quit exit
@server:uri attach a resource to your next message as context
/server:prompt-name k=v … run an MCP prompt as a slash command
Ctrl-C cancel the tool call that is currently running (otherwise exit)`;
export type InputResult = 'continue' | 'exit';
/** Print rows as an aligned two-column listing, one line per row, trimmed to the terminal width. */
function printAligned(ui: HostUI, rows: ReadonlyArray<readonly string[]>, emptyMessage: string): void {
if (rows.length === 0) {
ui.print(emptyMessage);
return;
}
const nameWidth = Math.min(Math.max(...rows.map(row => row[0]?.length ?? 0), 0), 48);
const columns = process.stdout.columns ?? 120;
for (const [name = '', description = ''] of rows) {
const line = `${name.padEnd(nameWidth)} ${description}`;
ui.print(line.length > columns ? `${line.slice(0, columns - 1)}…` : line);
}
}
/**
* Dispatch one line of user input: built-in slash commands, `/server:prompt` commands,
* or a plain chat message (with `@server:uri` attachments resolved first).
*/
export async function handleUserInput(session: ChatSession, input: string): Promise<InputResult> {
const { host, ui } = session;
const trimmed = input.trim();
if (!trimmed) return 'continue';
if (trimmed === '/quit' || trimmed === '/exit') return 'exit';
if (trimmed === '/help') {
ui.print(HELP);
return 'continue';
}
if (trimmed === '/servers') {
printAligned(
ui,
[...host.servers.values()].map(server => [
server.name,
`protocol ${server.protocolVersion}, ${server.tools.length} tools, ${server.resources.length} resources (+${server.resourceTemplates.length} templates), ${server.prompts.length} prompts`
]),
'[no servers connected]'
);
return 'continue';
}
if (trimmed === '/tools') {
printAligned(
ui,
host.toolDefinitions().map(tool => [tool.name, tool.description ?? '']),
'[no tools found — the connected servers expose none]'
);
return 'continue';
}
if (trimmed === '/resources') {
printAligned(
ui,
host.listResources().map(({ server, resource }) => [`@${server}:${resource.uri}`, getDisplayName(resource)]),
'[no resources found — the connected servers expose none]'
);
return 'continue';
}
if (trimmed === '/prompts') {
printAligned(
ui,
host.listPrompts().map(({ server, prompt }) => {
const args = (prompt.arguments ?? []).map(argument => `${argument.name}${argument.required ? '' : '?'}`).join(' ');
return [`/${server}:${prompt.name}${args ? ` ${args}` : ''}`, prompt.description ?? ''];
}),
'[no prompts found — the connected servers expose none]'
);
return 'continue';
}
if (trimmed === '/roots') {
for (const root of host.listRoots()) ui.print(root);
return 'continue';
}
if (trimmed.startsWith('/root add ')) {
await host.addRoot(trimmed.slice('/root add '.length).trim());
ui.status('root added');
return 'continue';
}
if (trimmed === '/watch' || trimmed.startsWith('/watch ')) {
const reference = trimmed.slice('/watch'.length).trim().replace(/^@/, '');
if (!reference) {
ui.print('usage: /watch @server:uri (see /resources)');
return 'continue';
}
try {
await host.watchResource(reference);
ui.note(`watching @${reference} — you'll get a note when it changes`);
} catch (error) {
ui.status(`could not watch @${reference}: ${error instanceof Error ? error.message : String(error)}`);
}
return 'continue';
}
// `/server:prompt-name key=value …` — MCP prompts become slash commands.
// Server names come straight from config keys and may contain dots etc. — accept the
// same shapes mention parsing does, so the commands /prompts advertises actually run.
const promptCommand = trimmed.match(/^\/([^\s:]+):(\S+)\s*(.*)$/);
if (promptCommand) {
const serverName = promptCommand[1] ?? '';
const promptName = promptCommand[2] ?? '';
const rest = promptCommand[3] ?? '';
const found = host.findPrompt(serverName, promptName);
if (!found) {
ui.print(`Unknown prompt: /${serverName}:${promptName} (see /prompts)`);
return 'continue';
}
const args = parsePromptArgs(rest);
for (const argument of found.prompt.arguments ?? []) {
if (argument.required && args[argument.name] === undefined) {
args[argument.name] = await ui.ask(
`[prompt argument] ${argument.name}${argument.description ? ` (${argument.description})` : ''}`
);
}
}
// The prompt's messages seed the conversation as-is — user and assistant turns stay
// distinct turns rather than being flattened into one block of text.
const messages = await host.getPromptMessages(found.server.name, found.prompt.name, args);
session.messages.push(...messages);
await runModelRounds(session);
return 'continue';
}
const { text, mentions } = extractMentions(trimmed);
const attachments: string[] = [];
for (const mention of mentions) {
try {
attachments.push(await host.attachResource(mention));
ui.note(`attached resource @${mention} as context`);
} catch (error) {
ui.status(
`could not attach @${mention}: ${error instanceof Error ? error.message : String(error)} — mentions look like @server:uri (see /resources)`
);
}
}
await runConversationTurn(session, text, attachments);
return 'continue';
}

View file

@ -0,0 +1,22 @@
/**
* Per-server tool names are namespaced `mcp__<server>__<tool>` before they reach the model
* (a common host convention), so two servers can both expose `search` and the host
* can always route a model-issued call back to the server that owns it.
*/
export function sanitizeServerName(name: string): string {
return name.replaceAll(/[^a-zA-Z0-9_-]/g, '_');
}
export function namespaceTool(serverKey: string, toolName: string): string {
return `mcp__${serverKey}__${toolName}`;
}
export function routeNamespacedTool(name: string, serverKeys: string[]): { serverKey: string; toolName: string } | undefined {
for (const serverKey of serverKeys.toSorted((a, b) => b.length - a.length)) {
const prefix = `mcp__${serverKey}__`;
if (name.startsWith(prefix) && name.length > prefix.length) {
return { serverKey, toolName: name.slice(prefix.length) };
}
}
return undefined;
}

View file

@ -0,0 +1,377 @@
import type { Interface } from 'node:readline/promises';
import { createInterface } from 'node:readline/promises';
import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client';
import { stripAnsi } from './content';
import type { McpHost } from './host';
// Minimal styling so a conversation is scannable: user input stays the terminal default,
// assistant prose is slightly dimmed, operational lines (tool calls, progress, logs) are grey,
// and anything that needs the user's decision (elicitation, approvals) is highlighted.
// Only applied on a TTY, so piped output stays plain text.
const useColor = process.stdout.isTTY === true;
function paint(style: string, text: string): string {
return useColor ? `[${style}m${text}` : text;
}
/**
* Everything cli-client ever shows or asks a human goes through this interface, so the e2e
* driver (client.ts) can swap in a scripted implementation and the rest of the host code
* stays identical. The two `confirm` call sites that matter for safety are the sampling
* approval gate and the OAuth browser-open prompt.
*/
export interface HostUI {
/** Assistant output and primary information. */
print(text: string): void;
/** Transient operational lines: tool calls, progress, connection events. A 'cancel' tone marks user cancellations. */
status(text: string, tone?: 'info' | 'cancel'): void;
/** Something that needs the user's decision next (elicitation forms, approval requests). */
attention(text: string): void;
/** Something that just became part of the conversation but isn't prose (an attached resource). */
note(text: string): void;
/** A log notification received from a server. */
serverLog(server: string, level: string, text: string): void;
/** Yes/no decision gate. Must default to "no" on uncertainty. */
confirm(question: string): Promise<boolean>;
/** Free-form question (elicitation form fields, prompt arguments). */
ask(question: string): Promise<string>;
/** Show a "working…" indicator until the returned stop function is called. */
spinner(): () => void;
/** While set, an interrupt (Ctrl-C) calls the handler instead of exiting the CLI. */
setCancelHandler(handler: (() => void) | undefined): void;
}
/**
* Just enough Markdown for a terminal — headings, bold, italic, inline code, bullets, and
* horizontal rules — so model prose doesn't read as raw `**` markup. Deliberately not a parser:
* anything it doesn't recognise passes through untouched.
*/
export function renderMarkdownLite(text: string): string {
return text
.split('\n')
.map(line => {
if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) return paint('2', '─'.repeat(40));
if (line.startsWith('```')) return paint('2', line);
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) return paint('1;4', heading[2] ?? '');
let rendered = line.replace(/^(\s*)[-*]\s+/, '$1• ');
rendered = rendered.replaceAll(/\*\*([^*]+)\*\*/g, (_match, inner: string) => paint('1', inner));
rendered = rendered.replaceAll(/(?<![\w*])\*([^*]+)\*(?![\w*])/g, (_match, inner: string) => paint('3', inner));
rendered = rendered.replaceAll(/`([^`]+)`/g, (_match, inner: string) => paint('36', inner));
return rendered;
})
.join('\n');
}
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SPINNER_WORDS = ['Pondering', 'Scheming', 'Brewing', 'Conjuring', 'Mulling', 'Percolating', 'Noodling', 'Ruminating'];
export class ReadlineUI implements HostUI {
private readonly rl: Interface;
/** Whether the previous output line was operational, so prose gets a separating blank line. */
private afterMeta = false;
/** Whether an attention block (sampling/elicitation/authorization) is open and awaiting its closing rule. */
private inAttentionBlock = false;
private spinnerTimer?: NodeJS.Timeout;
private cancelHandler?: () => void;
/** Ctrl-C cancels the in-flight tool call when one is running, and exits the CLI otherwise. */
private readonly handleInterrupt = (): void => {
if (this.cancelHandler) {
this.cancelHandler();
return;
}
this.rl.close();
// eslint-disable-next-line unicorn/no-process-exit
process.exit(130);
};
constructor(rl?: Interface) {
this.rl = rl ?? createInterface({ input: process.stdin, output: process.stdout });
this.rl.on('SIGINT', this.handleInterrupt);
process.on('SIGINT', this.handleInterrupt);
}
/** Everything that isn't user input or assistant prose shares a two-space gutter and stays on one line. */
private clipToWidth(text: string): string {
const columns = process.stdout.columns ?? 120;
return text.length > columns ? `${text.slice(0, Math.max(0, columns - 1))}…` : text;
}
private horizontalRule(): string {
return paint('2;33', ` ${'─'.repeat(Math.max(10, Math.min(process.stdout.columns ?? 80, 100) - 2))}`);
}
/** If a spinner is animating, wipe its line so real output never lands next to it. */
private clearSpinnerLine(): void {
if (this.spinnerTimer) this.clearLine();
}
/** Attention blocks are framed by horizontal rules; the closing rule prints when other output resumes. */
private closeAttentionBlock(): void {
if (!this.inAttentionBlock) return;
console.log(this.horizontalRule());
this.inAttentionBlock = false;
this.afterMeta = true;
}
/** The chat prompt itself also goes through the shared readline instance. */
async readUserInput(): Promise<string> {
this.closeAttentionBlock();
const answer = await this.rl.question(paint('1', '\n> '));
console.log();
this.afterMeta = false;
return answer.trim();
}
print(text: string): void {
// The assistant's prose: rendered with just-enough Markdown at the left margin, separated
// from any operational lines above so the conversation is easy to scan.
this.clearSpinnerLine();
this.closeAttentionBlock();
if (this.afterMeta) console.log();
console.log(renderMarkdownLite(stripAnsi(text)));
this.afterMeta = false;
}
status(text: string, tone: 'info' | 'cancel' = 'info'): void {
// Operational lines (tool calls, progress, connection events): grey italic, one line each.
// User cancellations get a red tag so they stand out from routine chatter.
this.clearSpinnerLine();
this.closeAttentionBlock();
if (tone === 'cancel') {
console.log(` ${paint('1;31', '[user cancellation]')} ${paint('90', this.clipToWidth(stripAnsi(text)))}`);
} else {
console.log(paint('3;90', this.clipToWidth(` · ${stripAnsi(text)}`)));
}
this.afterMeta = true;
}
attention(text: string): void {
// The user has to act on this next (elicitation form, approval): highlighted, framed by
// horizontal rules, with the first line as the block label and the rest indented under it.
this.clearSpinnerLine();
if (!this.inAttentionBlock) {
console.log(this.horizontalRule());
this.inAttentionBlock = true;
}
console.log(paint('1;33', ` ${stripAnsi(text).replaceAll('\n', '\n ')}`));
this.afterMeta = true;
}
note(text: string): void {
// Things that became part of the conversation but aren't prose (attached resources).
this.clearSpinnerLine();
this.closeAttentionBlock();
console.log(paint('36', this.clipToWidth(` ▍ ${stripAnsi(text)}`)));
this.afterMeta = true;
}
serverLog(server: string, level: string, text: string): void {
// Lines that originate on the server (notifications/message, child stderr) carry a tag.
this.clearSpinnerLine();
this.closeAttentionBlock();
const tag = level === 'stderr' ? `[${server} stderr]` : `[${server} notification]`;
const body = level === 'stderr' ? stripAnsi(text) : `${level}: ${stripAnsi(text)}`;
console.log(` ${paint('35', tag)} ${paint('90', this.clipToWidth(body))}`);
this.afterMeta = true;
}
async confirm(question: string): Promise<boolean> {
this.clearSpinnerLine();
const raw = await this.rl.question(paint('1;33', ` ${stripAnsi(question)} [y/N] `));
const answer = raw.trim().toLowerCase();
this.afterMeta = true;
return answer === 'y' || answer === 'yes';
}
async ask(question: string): Promise<string> {
this.clearSpinnerLine();
const answer = await this.rl.question(paint('1;33', ` ${stripAnsi(question)}: `));
this.afterMeta = true;
return answer.trim();
}
/** Stops the spinner and wipes its line; safe to call when no spinner is running. */
private readonly stopSpinner = (): void => {
if (this.spinnerTimer) clearInterval(this.spinnerTimer);
this.spinnerTimer = undefined;
this.clearLine();
};
/** Wipe the current terminal line (used to erase an in-place spinner frame). */
private clearLine(): void {
if (process.stdout.isTTY) process.stdout.write('\r');
}
spinner(): () => void {
// Animated "the model is thinking" line; redrawn in place and wiped before any real output.
if (!process.stdout.isTTY || this.spinnerTimer) return this.stopSpinner;
const startedAt = Date.now();
let tick = 0;
const render = (): void => {
const word = SPINNER_WORDS[Math.floor((Date.now() - startedAt) / 4000) % SPINNER_WORDS.length] ?? 'Working';
const seconds = Math.round((Date.now() - startedAt) / 1000);
const frame = SPINNER_FRAMES[tick % SPINNER_FRAMES.length] ?? '·';
process.stdout.write(`\r${paint('3;90', ` ${frame} ${word}… (${seconds}s)`)}`);
tick++;
};
render();
this.spinnerTimer = setInterval(render, 120);
return this.stopSpinner;
}
setCancelHandler(handler: (() => void) | undefined): void {
this.cancelHandler = handler;
}
close(): void {
this.rl.close();
}
}
type FormSchema = ElicitRequestFormParams['requestedSchema'];
type FormField = FormSchema['properties'][string];
type FormValue = string | number | boolean | string[];
/** Build the one-line prompt for a single elicitation form field. */
export function describeField(name: string, field: FormField, required: boolean): string {
const pieces: string[] = [`${field.title ?? name}`];
if (field.description) pieces.push(`(${field.description})`);
if ('enum' in field && field.enum) pieces.push(`[options: ${field.enum.join(', ')}]`);
if (field.type === 'boolean') pieces.push('[yes/no]');
if ((field.type === 'number' || field.type === 'integer') && (field.minimum !== undefined || field.maximum !== undefined)) {
pieces.push(`[${field.minimum ?? ''}..${field.maximum ?? ''}]`);
}
if ('default' in field && field.default !== undefined) {
pieces.push(`[default: ${String(field.default)}]`, '(Enter for the default)');
} else {
pieces.push(required ? '(required)' : '(optional — Enter to skip)');
}
return pieces.join(' ');
}
/** Parse one raw answer according to the field's primitive type; undefined means "invalid". */
export function parseFieldAnswer(field: FormField, answer: string): FormValue | undefined {
if (field.type === 'boolean') {
const lowered = answer.toLowerCase();
if (['y', 'yes', 'true'].includes(lowered)) return true;
if (['n', 'no', 'false'].includes(lowered)) return false;
return undefined;
}
if (field.type === 'number' || field.type === 'integer') {
const value = Number(answer);
if (Number.isNaN(value)) return undefined;
if (field.type === 'integer' && !Number.isInteger(value)) return undefined;
if (field.minimum !== undefined && value < field.minimum) return undefined;
if (field.maximum !== undefined && value > field.maximum) return undefined;
return value;
}
if (field.type === 'array') {
return answer
.split(',')
.map(item => item.trim())
.filter(item => item.length > 0);
}
if ('enum' in field && field.enum && !field.enum.includes(answer)) return undefined;
return answer;
}
/**
* Walk an elicitation form schema field by field, collecting answers through the UI.
* The user can answer `decline` or `cancel` at any field; errors fail closed to cancel.
* Note the three distinct outcomes — decline ("no") and cancel ("dismissed") are not the same.
*/
export async function collectFormInput(ui: HostUI, schema: FormSchema): Promise<ElicitResult> {
try {
const required = schema.required ?? [];
const content: Record<string, FormValue> = {};
for (const [name, field] of Object.entries(schema.properties)) {
const isRequired = required.includes(name);
for (let attempt = 0; attempt < 3; attempt++) {
const answer = await ui.ask(describeField(name, field, isRequired));
if (answer.toLowerCase() === 'decline') return { action: 'decline' };
if (answer.toLowerCase() === 'cancel') return { action: 'cancel' };
if (answer === '') {
if ('default' in field && field.default !== undefined) {
content[name] = field.default as FormValue;
break;
}
if (!isRequired) break;
ui.attention('this field is required (or answer "decline" / "cancel")');
continue;
}
const value = parseFieldAnswer(field, answer);
if (value === undefined) {
ui.attention('invalid value, try again');
continue;
}
content[name] = value;
break;
}
if (isRequired && !(name in content)) {
// Never return an accept that violates the requested schema.
ui.status('no valid answer for a required field — cancelling');
return { action: 'cancel' };
}
}
return { action: 'accept', content };
} catch {
return { action: 'cancel' };
}
}
const BUILTIN_COMMANDS = ['/help', '/servers', '/tools', '/resources', '/prompts', '/roots', '/root add ', '/watch ', '/quit', '/exit'];
/**
* Tab completion for the interactive CLI: slash commands and prompt names complete from the
* connected servers' prompt lists, `@server:uri` mentions complete from their resource lists,
* and prompt argument values complete through MCP `completion/complete` — the same data a
* richer host would put behind its picker UI. Tab completes the common prefix; a second Tab
* lists the remaining options (readline's standard behavior).
*/
export function createCompleter(getHost: () => McpHost | undefined): (line: string) => Promise<[string[], string]> {
return async line => {
try {
const host = getHost();
if (!host) return [[], line];
// `@server:uri` mentions — complete the current word from the cached resource lists.
const mention = /(^|\s)(@\S*)$/.exec(line)?.[2];
if (mention !== undefined) {
const candidates = [
...[...host.servers.keys()].map(name => `@${name}:`),
...host.listResources().map(({ server, resource }) => `@${server}:${resource.uri}`)
];
return [candidates.filter(candidate => candidate.startsWith(mention)), mention];
}
// `/server:prompt arg=value …` — complete argument names, and argument values via completion/complete.
const promptArgs = /^\/([^\s:]+):(\S+)\s+(?:.*\s)?([A-Za-z0-9_-]*)(=?)([^\s=]*)$/.exec(line);
if (promptArgs) {
const [, serverName = '', promptName = '', argumentName = '', equals = '', partial = ''] = promptArgs;
const found = host.findPrompt(serverName, promptName);
if (!found) return [[], line];
if (equals === '=') {
const values = await host.completePromptArgument(found.server.name, found.prompt.name, argumentName, partial);
const suggestions = values.map(value => (/\s/.test(value) ? `${argumentName}="${value}"` : `${argumentName}=${value}`));
return [suggestions, `${argumentName}=${partial}`];
}
const names = (found.prompt.arguments ?? []).map(argument => `${argument.name}=`);
return [names.filter(name => name.startsWith(argumentName)), argumentName];
}
// Slash commands and prompt commands.
if (line.startsWith('/') && !line.includes(' ')) {
const candidates = [...BUILTIN_COMMANDS, ...host.listPrompts().map(({ server, prompt }) => `/${server}:${prompt.name} `)];
return [candidates.filter(candidate => candidate.startsWith(line)), line];
}
return [[], line];
} catch {
return [[], line];
}
};
}

View file

@ -0,0 +1,33 @@
{
"name": "@mcp-examples/cli-client",
"private": true,
"type": "module",
"scripts": {
"start": "tsx cli.ts",
"client": "tsx client.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.74.0",
"@google/genai": "^1.0.0",
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"open": "^11.0.0",
"openai": "^6.0.0",
"zod": "catalog:runtimeShared"
},
"devDependencies": {
"tsx": "catalog:devTools",
"vitest": "catalog:devTools"
},
"example": {
"era": "dual",
"timeoutMs": 60000,
"expects": {
"stdout": "cli-client e2e: all checks passed"
},
"shapeExempt": "Reference host, not a single-feature story: a multi-file package (host/, providers/, script/, test/) whose server lives in the sibling examples/todos-server package. server.ts here is a thin shim so the runner's http legs can spawn the paired server.",
"//": "client.ts replays a scripted conversation (ScriptedProvider, no API keys) against the sibling todos-server. All four transport/era legs run; the sampling-backed prioritize tool, the multi-round brainstorm flow, and the elicitation-confirmed clear_done tool are exercised on the modern legs and on stdio/legacy, and skipped on http/legacy (createMcpHandler's stateless legacy posture has no return path for push-style server→client requests). cli.ts is the interactive entry for humans."
}
}

View file

@ -0,0 +1,141 @@
import Anthropic from '@anthropic-ai/sdk';
import type { ChatMessage, ContentPart, GenerateRequest, GenerateResult, LLMProvider, ToolCall } from './provider';
import { isRecord } from './provider';
/** One provider-neutral content part → one Anthropic block (text passthrough, supported images, placeholder otherwise). */
function partToBlock(part: ContentPart): Anthropic.TextBlockParam | Anthropic.ImageBlockParam {
if (part.type === 'text') {
return { type: 'text', text: part.text };
}
if (
part.mimeType === 'image/jpeg' ||
part.mimeType === 'image/png' ||
part.mimeType === 'image/gif' ||
part.mimeType === 'image/webp'
) {
return { type: 'image', source: { type: 'base64', media_type: part.mimeType, data: part.data } };
}
return { type: 'text', text: `[image omitted: unsupported media type ${part.mimeType}]` };
}
function toContentBlocks(message: ChatMessage): Anthropic.ContentBlockParam[] {
return message.content.filter(part => part.type !== 'text' || part.text.length > 0).map(part => partToBlock(part));
}
/**
* Convert the provider-neutral request into Anthropic Messages API parameters.
*
* The mapping every host writes for the Anthropic Messages API:
* - MCP tool definitions pass straight through — `inputSchema` is already JSON Schema.
* - Assistant tool calls become `tool_use` blocks; tool results become `tool_result` blocks
* inside a *user* message, and results for parallel tool calls must share one user message.
* - `isError` from MCP becomes `is_error` so the model knows the call failed.
*/
export function toAnthropicRequest(request: GenerateRequest, model: string): Anthropic.MessageCreateParamsNonStreaming {
const messages: Anthropic.MessageParam[] = [];
for (const message of request.messages) {
if (message.role === 'tool') {
const resultBlock: Anthropic.ToolResultBlockParam = {
type: 'tool_result',
tool_use_id: message.toolCallId,
is_error: message.isError ?? false,
content: message.content.map(part => partToBlock(part))
};
const previous = messages.at(-1);
if (previous?.role === 'user' && Array.isArray(previous.content)) {
previous.content.push(resultBlock);
} else {
messages.push({ role: 'user', content: [resultBlock] });
}
continue;
}
if (message.role === 'assistant') {
const blocks: Anthropic.ContentBlockParam[] = toContentBlocks(message);
for (const call of message.toolCalls ?? []) {
blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments });
}
if (blocks.length > 0) messages.push({ role: 'assistant', content: blocks });
continue;
}
const blocks = toContentBlocks(message);
if (blocks.length > 0) messages.push({ role: 'user', content: blocks });
}
return {
model,
max_tokens: request.maxTokens ?? 1024,
...(request.temperature === undefined ? {} : { temperature: request.temperature }),
...(request.system === undefined ? {} : { system: request.system }),
messages,
tools: (request.tools ?? []).map(tool => ({
name: tool.name,
description: tool.description ?? '',
input_schema: { ...tool.inputSchema, type: 'object' }
}))
};
}
/** Pull text + tool calls back out of an Anthropic response. */
export function fromAnthropicResponse(response: Anthropic.Message): GenerateResult {
const textParts: string[] = [];
const toolCalls: ToolCall[] = [];
for (const block of response.content) {
if (block.type === 'text') {
textParts.push(block.text);
} else if (block.type === 'tool_use') {
toolCalls.push({ id: block.id, name: block.name, arguments: isRecord(block.input) ? block.input : {} });
}
}
const stopReason: GenerateResult['stopReason'] =
response.stop_reason === 'tool_use'
? 'tool_use'
: response.stop_reason === 'max_tokens'
? 'max_tokens'
: response.stop_reason === 'end_turn' || response.stop_reason === 'stop_sequence'
? 'end_turn'
: 'other';
return { text: textParts.join('\n'), toolCalls, stopReason, model: response.model };
}
export class AnthropicProvider implements LLMProvider {
readonly name = 'anthropic';
private readonly client: Anthropic;
private model?: string;
constructor(model?: string) {
// The SDK reads either an API key or a bearer token (ANTHROPIC_AUTH_TOKEN) from the env.
if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) {
throw new Error('Neither ANTHROPIC_API_KEY nor ANTHROPIC_AUTH_TOKEN is set — export one or pick a different --provider');
}
this.client = new Anthropic();
this.model = model ?? process.env.ANTHROPIC_MODEL;
}
/**
* Model ids change faster than examples do, so nothing is hardcoded here: unless pinned
* via `--model` / `ANTHROPIC_MODEL`, ask the API for its model list and use the newest
* Sonnet-class (mid-tier) model.
*/
private async resolveModel(): Promise<string> {
if (this.model) return this.model;
const models = await this.client.models.list({ limit: 100 });
const newestSonnet = models.data
.filter(model => model.id.includes('sonnet'))
.toSorted((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0];
if (!newestSonnet) {
throw new Error('No Sonnet-class model found on the Anthropic API — pass --model or set ANTHROPIC_MODEL');
}
this.model = newestSonnet.id;
return this.model;
}
async generate(request: GenerateRequest): Promise<GenerateResult> {
const model = await this.resolveModel();
const response = await this.client.messages.create(toAnthropicRequest(request, model));
return fromAnthropicResponse(response);
}
}

View file

@ -0,0 +1,127 @@
import type { Content, FunctionDeclaration, GenerateContentParameters, GenerateContentResponse, Part } from '@google/genai';
import { GoogleGenAI } from '@google/genai';
import type { ChatMessage, GenerateRequest, GenerateResult, LLMProvider, ToolCall } from './provider';
import { isRecord, partsToText } from './provider';
function toParts(message: ChatMessage): Part[] {
return message.content
.filter(part => part.type !== 'text' || part.text.length > 0)
.map(part => (part.type === 'text' ? { text: part.text } : { inlineData: { mimeType: part.mimeType, data: part.data } }));
}
/**
* Convert the provider-neutral request into `generateContent` parameters.
*
* The mapping every host writes for Gemini:
* - MCP `inputSchema` passes through as `parametersJsonSchema` (raw JSON Schema — no
* conversion to the OpenAPI-style `parameters` subset needed).
* - Assistant tool calls become `functionCall` parts; tool results go back as
* `functionResponse` parts keyed by the *function name* (Gemini has no call ids on the
* wire, so cli-client's generated ids stay host-side).
* - Conversation roles are `user` / `model`.
*/
export function toGeminiRequest(request: GenerateRequest, model: string): GenerateContentParameters {
const contents: Content[] = [];
for (const message of request.messages) {
if (message.role === 'tool') {
const responsePart: Part = {
functionResponse: {
name: message.toolName,
response: { content: partsToText(message.content), ...(message.isError ? { isError: true } : {}) }
}
};
// Results for parallel function calls must arrive together in one user turn.
const previous = contents.at(-1);
if (previous?.role === 'user' && previous.parts?.every(part => part.functionResponse)) {
previous.parts.push(responsePart);
} else {
contents.push({ role: 'user', parts: [responsePart] });
}
} else if (message.role === 'assistant') {
const parts: Part[] = toParts(message);
for (const call of message.toolCalls ?? []) {
parts.push({ functionCall: { name: call.name, args: call.arguments } });
}
if (parts.length > 0) contents.push({ role: 'model', parts });
} else {
const parts = toParts(message);
if (parts.length > 0) contents.push({ role: 'user', parts });
}
}
const functionDeclarations: FunctionDeclaration[] = (request.tools ?? []).map(tool => ({
name: tool.name,
description: tool.description ?? '',
parametersJsonSchema: tool.inputSchema
}));
return {
model,
contents,
config: {
...(request.system === undefined ? {} : { systemInstruction: request.system }),
...(request.maxTokens === undefined ? {} : { maxOutputTokens: request.maxTokens }),
...(request.temperature === undefined ? {} : { temperature: request.temperature }),
...(functionDeclarations.length > 0 ? { tools: [{ functionDeclarations }] } : {})
}
};
}
/** Pull text + tool calls back out of a `generateContent` response. */
export function fromGeminiResponse(response: GenerateContentResponse, model: string): GenerateResult {
const toolCalls: ToolCall[] = (response.functionCalls ?? []).map((call, index) => ({
id: call.id ?? `call_${index + 1}`,
name: call.name ?? '',
arguments: isRecord(call.args) ? call.args : {}
}));
const finishReason = String(response.candidates?.[0]?.finishReason ?? '');
const stopReason: GenerateResult['stopReason'] =
toolCalls.length > 0 ? 'tool_use' : finishReason === 'MAX_TOKENS' ? 'max_tokens' : finishReason === 'STOP' ? 'end_turn' : 'other';
return { text: response.text ?? '', toolCalls, stopReason, model };
}
export class GeminiProvider implements LLMProvider {
readonly name = 'gemini';
private readonly client: GoogleGenAI;
private model?: string;
constructor(model?: string) {
if (!process.env.GEMINI_API_KEY) {
throw new Error('GEMINI_API_KEY is not set — export it or pick a different --provider');
}
this.client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
this.model = model ?? process.env.GEMINI_MODEL;
}
/**
* Model ids change faster than examples do, so nothing is hardcoded here: unless pinned
* via `--model` / `GEMINI_MODEL`, ask the API for its model list and use the newest
* stable Flash (mid-tier) model.
*/
private async resolveModel(): Promise<string> {
if (this.model) return this.model;
const stableFlash = /^models\/gemini-(\d+(?:\.\d+)?)-flash$/;
let newest: { id: string; version: number } | undefined;
for await (const model of await this.client.models.list()) {
const name = model.name;
const match = name?.match(stableFlash);
if (!name || !match) continue;
const version = Number.parseFloat(match[1] ?? '0');
if (!newest || version > newest.version) {
newest = { id: name.replace(/^models\//, ''), version };
}
}
if (!newest) {
throw new Error('No stable gemini-<version>-flash model found on the Gemini API — pass --model or set GEMINI_MODEL');
}
this.model = newest.id;
return this.model;
}
async generate(request: GenerateRequest): Promise<GenerateResult> {
const model = await this.resolveModel();
const response = await this.client.models.generateContent(toGeminiRequest(request, model));
return fromGeminiResponse(response, model);
}
}

View file

@ -0,0 +1,139 @@
import OpenAI from 'openai';
import type { GenerateRequest, GenerateResult, LLMProvider, ToolCall } from './provider';
import { isRecord, partsToText } from './provider';
/**
* Convert the provider-neutral request into Chat Completions parameters.
*
* The mapping every host writes for OpenAI-compatible APIs:
* - MCP `inputSchema` passes straight through as the function `parameters` (JSON Schema).
* - Assistant tool calls become `tool_calls` with JSON-encoded arguments; tool results become
* `role: 'tool'` messages keyed by `tool_call_id`.
* - Chat Completions tool messages are text-only, so failures are prefixed with `[tool error]`
* and images are reduced to placeholders.
*/
export function toOpenAIRequest(request: GenerateRequest, model: string): OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
if (request.system) {
messages.push({ role: 'system', content: request.system });
}
for (const message of request.messages) {
if (message.role === 'tool') {
const text = partsToText(message.content);
messages.push({
role: 'tool',
tool_call_id: message.toolCallId,
content: message.isError ? `[tool error] ${text}` : text
});
} else if (message.role === 'assistant') {
const toolCalls = (message.toolCalls ?? []).map(call => ({
id: call.id,
type: 'function' as const,
function: { name: call.name, arguments: JSON.stringify(call.arguments) }
}));
const text = partsToText(message.content);
// The API rejects assistant messages that carry neither content nor tool calls.
if (!text && toolCalls.length === 0) continue;
messages.push({
role: 'assistant',
content: text || null,
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {})
});
} else {
messages.push({
role: 'user',
content: message.content.map(part =>
part.type === 'text'
? { type: 'text' as const, text: part.text }
: { type: 'image_url' as const, image_url: { url: `data:${part.mimeType};base64,${part.data}` } }
)
});
}
}
return {
model,
messages,
...(request.maxTokens === undefined ? {} : { max_completion_tokens: request.maxTokens }),
...(request.temperature === undefined ? {} : { temperature: request.temperature }),
...((request.tools ?? []).length > 0
? {
tools: (request.tools ?? []).map(tool => ({
type: 'function' as const,
function: { name: tool.name, description: tool.description ?? '', parameters: tool.inputSchema }
}))
}
: {})
};
}
/** Pull text + tool calls back out of a Chat Completions response. */
export function fromOpenAIResponse(response: OpenAI.Chat.Completions.ChatCompletion): GenerateResult {
const choice = response.choices[0];
const toolCalls: ToolCall[] = [];
for (const call of choice?.message.tool_calls ?? []) {
if (call.type !== 'function') continue;
let parsed: unknown;
try {
parsed = JSON.parse(call.function.arguments || '{}');
} catch {
parsed = {};
}
toolCalls.push({ id: call.id, name: call.function.name, arguments: isRecord(parsed) ? parsed : {} });
}
const finishReason = choice?.finish_reason;
const stopReason: GenerateResult['stopReason'] =
finishReason === 'tool_calls'
? 'tool_use'
: finishReason === 'length'
? 'max_tokens'
: finishReason === 'stop'
? 'end_turn'
: 'other';
return { text: choice?.message.content ?? '', toolCalls, stopReason, model: response.model };
}
/**
* Works against api.openai.com by default; point `OPENAI_BASE_URL` at any Chat-Completions
* compatible endpoint (Gemini's compatibility layer, Ollama, vLLM, …) to reuse this mapping.
*/
export class OpenAIProvider implements LLMProvider {
readonly name = 'openai';
private readonly client: OpenAI;
private model?: string;
constructor(model?: string) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not set — export it or pick a different --provider');
}
this.client = new OpenAI({ baseURL: process.env.OPENAI_BASE_URL });
this.model = model ?? process.env.OPENAI_MODEL;
}
/**
* Model ids change faster than examples do, so nothing is hardcoded here: unless pinned
* via `--model` / `OPENAI_MODEL`, ask the API for its model list and use the newest
* mainline `gpt-<version>` model (the mid-tier one — not -pro, -mini, or -nano variants).
*/
private async resolveModel(): Promise<string> {
if (this.model) return this.model;
const mainline = /^gpt-\d+(?:\.\d+)?$/;
let newest: { id: string; created: number } | undefined;
for await (const model of this.client.models.list()) {
if (mainline.test(model.id) && (!newest || model.created > newest.created)) {
newest = model;
}
}
if (!newest) {
throw new Error('No mainline gpt-<version> model found on the OpenAI API — pass --model or set OPENAI_MODEL');
}
this.model = newest.id;
return this.model;
}
async generate(request: GenerateRequest): Promise<GenerateResult> {
const model = await this.resolveModel();
const response = await this.client.chat.completions.create(toOpenAIRequest(request, model));
return fromOpenAIResponse(response);
}
}

View file

@ -0,0 +1,71 @@
/**
* The seam where MCP meets the model.
*
* `LLMProvider` is the only thing the rest of cli-client knows about a language model: given
* the conversation so far and the MCP tools currently available, produce the next assistant
* turn (text and/or tool calls). Each file in providers/ is the complete mapping for one
* provider API — if you are building your own host, copy the one for the provider you use.
*
* The same interface serves both directions: the chat loop calls it to drive the
* conversation, and the MCP sampling handler calls it to answer `sampling/createMessage`
* requests from servers.
*/
export interface ToolDefinition {
/** Namespaced tool name as exposed to the model (e.g. `mcp__todos__add_task`). */
name: string;
description?: string;
/** JSON Schema for the tool's arguments, passed through from the MCP `Tool.inputSchema`. */
inputSchema: Record<string, unknown>;
}
export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string };
export interface ToolCall {
/** Provider-assigned id, echoed back on the matching `role: 'tool'` message. */
id: string;
/** Namespaced tool name (matches a `ToolDefinition.name`). */
name: string;
arguments: Record<string, unknown>;
}
export type ChatMessage =
| { role: 'user'; content: ContentPart[] }
| { role: 'assistant'; content: ContentPart[]; toolCalls?: ToolCall[] }
| { role: 'tool'; toolCallId: string; toolName: string; content: ContentPart[]; isError?: boolean };
export interface GenerateRequest {
system?: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
maxTokens?: number;
temperature?: number;
}
export interface GenerateResult {
/** Assistant prose (may be empty when the model only calls tools). */
text: string;
/** Tool calls the host must execute and feed back as `role: 'tool'` messages. */
toolCalls: ToolCall[];
stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'other';
/** Provider-reported model id (also used to answer MCP sampling requests). */
model: string;
}
export interface LLMProvider {
readonly name: string;
generate(request: GenerateRequest): Promise<GenerateResult>;
}
export function textPart(text: string): ContentPart {
return { type: 'text', text };
}
/** Flatten content parts to plain text, replacing non-text parts with a placeholder. */
export function partsToText(parts: ContentPart[]): string {
return parts.map(part => (part.type === 'text' ? part.text : `[image: ${part.mimeType}]`)).join('\n');
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,46 @@
import type { GenerateRequest, GenerateResult, LLMProvider, ToolCall } from './provider';
export interface ScriptedTurn {
/** Optional inspection hook — the e2e driver uses it to assert on the request the host built. */
expect?: (request: GenerateRequest) => void;
text?: string;
toolCalls?: ToolCall[];
}
/**
* Replays a fixed sequence of assistant turns. No keys, no network — this is what CI runs
* (`client.ts`) and what `--provider scripted` gives you locally. Each `generate()` call
* consumes the next turn in order; because the MCP sampling handler goes through the same
* provider, sampling requests consume turns too.
*/
export class ScriptedProvider implements LLMProvider {
readonly name = 'scripted';
private next = 0;
constructor(private readonly turns: ScriptedTurn[] = []) {}
/** Turns that have not been consumed yet (the e2e driver asserts this reaches 0). */
get remaining(): number {
return Math.max(0, this.turns.length - this.next);
}
generate(request: GenerateRequest): Promise<GenerateResult> {
const turn = this.turns[this.next++];
if (!turn) {
return Promise.resolve({
text: '(scripted provider has no turns left — run with --provider anthropic|openai|gemini for a real model)',
toolCalls: [],
stopReason: 'end_turn',
model: 'scripted'
});
}
turn.expect?.(request);
const toolCalls = turn.toolCalls ?? [];
return Promise.resolve({
text: turn.text ?? '',
toolCalls,
stopReason: toolCalls.length > 0 ? 'tool_use' : 'end_turn',
model: 'scripted'
});
}
}

View file

@ -0,0 +1,83 @@
import type { HostUI } from '../host/ui';
/**
* The UI used by the e2e driver: answers come from pre-loaded queues instead of a human, and
* everything the host would have shown is recorded so the driver can assert on it afterwards.
* Unanswered confirmations fail closed (false), like a human who walked away.
*/
export class ScriptedUI implements HostUI {
readonly printed: string[] = [];
readonly statuses: string[] = [];
readonly serverLogs: Array<{ server: string; level: string; text: string }> = [];
readonly questions: string[] = [];
private readonly confirmAnswers: boolean[];
private readonly askAnswers: string[];
private cancelHandler?: () => void;
/** When set, the next status() line that includes this substring fires the in-flight tool-call cancel handler once. */
cancelOnStatusMatching?: string;
constructor(options: { confirmAnswers?: boolean[]; askAnswers?: string[] } = {}) {
this.confirmAnswers = [...(options.confirmAnswers ?? [])];
this.askAnswers = [...(options.askAnswers ?? [])];
}
get unansweredConfirms(): number {
return this.confirmAnswers.length;
}
get unansweredAsks(): number {
return this.askAnswers.length;
}
print(text: string): void {
this.printed.push(text);
console.log(text);
}
attention(text: string): void {
this.printed.push(text);
console.log(text);
}
status(text: string): void {
this.statuses.push(text);
console.log(` · ${text}`);
if (this.cancelOnStatusMatching && text.includes(this.cancelOnStatusMatching) && this.cancelHandler) {
this.cancelOnStatusMatching = undefined;
this.cancelHandler();
}
}
note(text: string): void {
this.statuses.push(text);
console.log(` ▍ ${text}`);
}
serverLog(server: string, level: string, text: string): void {
this.serverLogs.push({ server, level, text });
console.log(` [${server}] ${level}: ${text}`);
}
confirm(question: string): Promise<boolean> {
this.questions.push(question);
return Promise.resolve(this.confirmAnswers.shift() ?? false);
}
ask(question: string): Promise<string> {
this.questions.push(question);
return Promise.resolve(this.askAnswers.shift() ?? '');
}
spinner(): () => void {
return noop;
}
setCancelHandler(handler: (() => void) | undefined): void {
this.cancelHandler = handler;
}
}
function noop(): void {
// The scripted driver has no spinner to stop.
}

Some files were not shown because too many files have changed in this diff Show more