feat(examples): add a reference host/server pair — cli-client + todos-server (#2380)
This commit is contained in:
parent
1823aae891
commit
ba27d201e1
7 changed files with 4698 additions and 25 deletions
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];
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue