feat(examples): add a reference host/server pair — cli-client + todos-server (#2380)
This commit is contained in:
parent
1823aae891
commit
ba27d201e1
31 changed files with 4698 additions and 25 deletions
|
|
@ -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
1
examples/cli-client/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
config.json
|
||||
157
examples/cli-client/README.md
Normal file
157
examples/cli-client/README.md
Normal 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
154
examples/cli-client/cli.ts
Normal 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();
|
||||
}
|
||||
48
examples/cli-client/client.ts
Normal file
48
examples/cli-client/client.ts
Normal 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');
|
||||
8
examples/cli-client/config.example.json
Normal file
8
examples/cli-client/config.example.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"todos": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "tsx", "../todos-server/server.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
213
examples/cli-client/host/auth.ts
Normal file
213
examples/cli-client/host/auth.ts
Normal 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;
|
||||
}
|
||||
115
examples/cli-client/host/config.ts
Normal file
115
examples/cli-client/host/config.ts
Normal 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 };
|
||||
}
|
||||
98
examples/cli-client/host/content.ts
Normal file
98
examples/cli-client/host/content.ts
Normal 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();
|
||||
}
|
||||
542
examples/cli-client/host/host.ts
Normal file
542
examples/cli-client/host/host.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
273
examples/cli-client/host/loop.ts
Normal file
273
examples/cli-client/host/loop.ts
Normal 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';
|
||||
}
|
||||
22
examples/cli-client/host/naming.ts
Normal file
22
examples/cli-client/host/naming.ts
Normal 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;
|
||||
}
|
||||
377
examples/cli-client/host/ui.ts
Normal file
377
examples/cli-client/host/ui.ts
Normal 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}[0m` : 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[2K');
|
||||
}
|
||||
|
||||
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[2K${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];
|
||||
}
|
||||
};
|
||||
}
|
||||
33
examples/cli-client/package.json
Normal file
33
examples/cli-client/package.json
Normal 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."
|
||||
}
|
||||
}
|
||||
141
examples/cli-client/providers/anthropic.ts
Normal file
141
examples/cli-client/providers/anthropic.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
127
examples/cli-client/providers/gemini.ts
Normal file
127
examples/cli-client/providers/gemini.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
139
examples/cli-client/providers/openai.ts
Normal file
139
examples/cli-client/providers/openai.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
71
examples/cli-client/providers/provider.ts
Normal file
71
examples/cli-client/providers/provider.ts
Normal 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);
|
||||
}
|
||||
46
examples/cli-client/providers/scripted.ts
Normal file
46
examples/cli-client/providers/scripted.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
83
examples/cli-client/script/scriptedUi.ts
Normal file
83
examples/cli-client/script/scriptedUi.ts
Normal 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
Loading…
Reference in a new issue