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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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