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
4 changed files with 4698 additions and 25 deletions

View file

@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import {
CliOAuthClientProvider,
completeAuthorizationWithBrowser,
createOAuthProvider,
findCallbackPort,
isSafeBrowserUrl,
waitForOAuthCallback
} from '../host/auth';
import { ScriptedUI } from '../script/scriptedUi';
/** Poll the loopback callback endpoint until the listener is up, then deliver the query. */
async function deliverCallback(port: number, query: string): Promise<void> {
for (let attempt = 0; attempt < 50; attempt++) {
try {
await fetch(`http://127.0.0.1:${port}/callback?${query}`);
return;
} catch {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
throw new Error('callback server never came up');
}
describe('CliOAuthClientProvider', () => {
it('round-trips the state the SDK stores on it and supports scoped invalidation', () => {
const provider = createOAuthProvider('todos', 8123);
expect(provider.clientMetadata.redirect_uris).toEqual(['http://127.0.0.1:8123/callback']);
expect(provider.state()).toBe(provider.state());
provider.saveTokens({ access_token: 'a', token_type: 'bearer' });
provider.saveCodeVerifier('verifier');
provider.saveClientInformation({ client_id: 'client' });
expect(provider.tokens()?.access_token).toBe('a');
expect(provider.codeVerifier()).toBe('verifier');
provider.invalidateCredentials('tokens');
expect(provider.tokens()).toBeUndefined();
expect(provider.clientInformation()?.client_id).toBe('client');
provider.invalidateCredentials('all');
expect(provider.clientInformation()).toBeUndefined();
});
it('defers the redirect instead of opening anything during connect()', () => {
const provider = new CliOAuthClientProvider('http://localhost:1/callback', { redirect_uris: ['http://localhost:1/callback'] });
provider.redirectToAuthorization(new URL('https://auth.example.com/authorize?state=s'));
expect(provider.pendingAuthorizationUrl?.hostname).toBe('auth.example.com');
});
});
describe('loopback callback server', () => {
it('resolves with the callback query parameters', async () => {
const port = await findCallbackPort();
const callback = waitForOAuthCallback(port);
await deliverCallback(port, 'code=abc&state=xyz');
const params = await callback;
expect(params.get('code')).toBe('abc');
expect(params.get('state')).toBe('xyz');
});
});
describe('completeAuthorizationWithBrowser', () => {
it('does nothing when the user declines', async () => {
const provider = createOAuthProvider('todos', 8124);
provider.redirectToAuthorization(new URL('http://127.0.0.1:9/authorize'));
let exchanged = false;
const authorized = await completeAuthorizationWithBrowser({
serverName: 'todos',
ui: new ScriptedUI({ confirmAnswers: [false] }),
provider,
callbackPort: 8124,
finishAuth: async () => {
exchanged = true;
}
});
expect(authorized).toBe(false);
expect(exchanged).toBe(false);
});
it('refuses to open a non-https, non-loopback authorization URL', async () => {
const provider = createOAuthProvider('todos', 8125);
provider.redirectToAuthorization(new URL('http://auth.example.com/authorize'));
let opened = false;
const authorized = await completeAuthorizationWithBrowser({
serverName: 'todos',
ui: new ScriptedUI({ confirmAnswers: [true] }),
provider,
callbackPort: 8125,
finishAuth: async () => {},
openUrl: async () => {
opened = true;
}
});
expect(authorized).toBe(false);
expect(opened).toBe(false);
});
it('rejects a callback whose state does not match', async () => {
const port = await findCallbackPort();
const provider = createOAuthProvider('todos', port);
const expectedState = provider.state();
provider.redirectToAuthorization(new URL(`http://127.0.0.1:9/authorize?state=${expectedState}`));
let exchanged = false;
const pending = completeAuthorizationWithBrowser({
serverName: 'todos',
ui: new ScriptedUI({ confirmAnswers: [true] }),
provider,
callbackPort: port,
finishAuth: async () => {
exchanged = true;
},
openUrl: async () => {}
});
await deliverCallback(port, 'code=abc&state=wrong');
expect(await pending).toBe(false);
expect(exchanged).toBe(false);
});
it('exchanges the code when the state matches', async () => {
const port = await findCallbackPort();
const provider = createOAuthProvider('todos', port);
const expectedState = provider.state();
provider.redirectToAuthorization(new URL(`http://127.0.0.1:9/authorize?state=${expectedState}`));
let receivedCode: string | null = null;
let openedUrl: string | undefined;
const pending = completeAuthorizationWithBrowser({
serverName: 'todos',
ui: new ScriptedUI({ confirmAnswers: [true] }),
provider,
callbackPort: port,
finishAuth: async params => {
receivedCode = params.get('code');
},
openUrl: async url => {
openedUrl = url;
}
});
await deliverCallback(port, `code=secret-code&state=${expectedState}`);
expect(await pending).toBe(true);
expect(receivedCode).toBe('secret-code');
expect(openedUrl).toBe(`http://127.0.0.1:9/authorize?state=${expectedState}`);
});
});
describe('isSafeBrowserUrl', () => {
it('allows https anywhere and http only on loopback', () => {
expect(isSafeBrowserUrl(new URL('https://example.com/step'))).toBe(true);
expect(isSafeBrowserUrl(new URL('http://127.0.0.1:8080/cb'))).toBe(true);
expect(isSafeBrowserUrl(new URL('http://localhost/cb'))).toBe(true);
expect(isSafeBrowserUrl(new URL('http://[::1]:9000/cb'))).toBe(true);
});
it('refuses remote http and non-web schemes', () => {
expect(isSafeBrowserUrl(new URL('http://example.com/phish'))).toBe(false);
expect(isSafeBrowserUrl(new URL('file:///etc/passwd'))).toBe(false);
expect(isSafeBrowserUrl(new URL('javascript:alert(1)'))).toBe(false);
expect(isSafeBrowserUrl(new URL('ftp://example.com/x'))).toBe(false);
});
});

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { extractMentions, parsePromptArgs } from '../host/loop';
import { collectFormInput, describeField, parseFieldAnswer } from '../host/ui';
import { ScriptedUI } from '../script/scriptedUi';
const SCHEMA = {
type: 'object' as const,
properties: {
confirm: { type: 'boolean' as const, title: 'Really?' },
count: { type: 'string' as const, enum: ['5', '10', '20', '50', 'custom'] },
name: { type: 'string' as const, description: 'Your name' }
},
required: ['confirm', 'count']
};
describe('elicitation form helpers', () => {
it('describes fields with their constraints', () => {
expect(describeField('confirm', SCHEMA.properties.confirm, true)).toContain('Really?');
expect(describeField('confirm', SCHEMA.properties.confirm, true)).toContain('(required)');
expect(describeField('count', SCHEMA.properties.count, false)).toContain('options: 5, 10, 20, 50, custom');
});
it('parses answers per primitive type and rejects invalid values', () => {
expect(parseFieldAnswer({ type: 'boolean' }, 'y')).toBe(true);
expect(parseFieldAnswer({ type: 'boolean' }, 'maybe')).toBeUndefined();
expect(parseFieldAnswer({ type: 'integer', minimum: 1, maximum: 10 }, '5')).toBe(5);
expect(parseFieldAnswer({ type: 'integer', minimum: 1, maximum: 10 }, '50')).toBeUndefined();
expect(parseFieldAnswer({ type: 'integer' }, '2.5')).toBeUndefined();
expect(parseFieldAnswer({ type: 'string', enum: ['a', 'b'] }, 'c')).toBeUndefined();
expect(parseFieldAnswer({ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, 'x, y')).toEqual(['x', 'y']);
});
it('collects a full form through the UI', async () => {
const ui = new ScriptedUI({ askAnswers: ['y', '10', 'Felix'] });
const result = await collectFormInput(ui, SCHEMA);
expect(result).toEqual({ action: 'accept', content: { confirm: true, count: '10', name: 'Felix' } });
expect(ui.questions.some(question => question.includes('Really?'))).toBe(true);
});
it('treats decline and cancel as terminal answers and retries invalid input', async () => {
expect(await collectFormInput(new ScriptedUI({ askAnswers: ['decline'] }), SCHEMA)).toEqual({ action: 'decline' });
expect(await collectFormInput(new ScriptedUI({ askAnswers: ['cancel'] }), SCHEMA)).toEqual({ action: 'cancel' });
const retrying = new ScriptedUI({ askAnswers: ['maybe', 'y', '10', ''] });
expect(await collectFormInput(retrying, SCHEMA)).toEqual({ action: 'accept', content: { confirm: true, count: '10' } });
});
it('cancels rather than accepting when a required field never gets a valid answer', async () => {
const ui = new ScriptedUI({ askAnswers: ['maybe', 'maybe', 'maybe'] });
expect(await collectFormInput(ui, SCHEMA)).toEqual({ action: 'cancel' });
});
});
describe('input parsing', () => {
it('extracts @server:uri mentions', () => {
const { text, mentions } = extractMentions('@todos:todos://board what should I do first?');
expect(mentions).toEqual(['todos:todos://board']);
expect(text).toContain('what should I do first?');
});
it('parses key=value prompt arguments, including quoted values', () => {
expect(parsePromptArgs('focus=cli-client note="ship it today"')).toEqual({ focus: 'cli-client', note: 'ship it today' });
});
});

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { configFromTargets, interpolateEnv, isHttpServer, parseConfig } from '../host/config';
import { contentBlockToParts, resourceToContextText, stripAnsi, toolResultToParts, truncate } from '../host/content';
import { namespaceTool, routeNamespacedTool, sanitizeServerName } from '../host/naming';
describe('tool namespacing and routing', () => {
it('sanitizes server names the way provider tool-name rules require', () => {
expect(sanitizeServerName('todos')).toBe('todos');
expect(sanitizeServerName('my server.prod')).toBe('my_server_prod');
});
it('routes namespaced calls back to the owning server, longest key first', () => {
const keys = ['todos', 'todos_staging'];
expect(routeNamespacedTool(namespaceTool('todos', 'add_task'), keys)).toEqual({ serverKey: 'todos', toolName: 'add_task' });
expect(routeNamespacedTool('mcp__todos_staging__add_task', keys)).toEqual({ serverKey: 'todos_staging', toolName: 'add_task' });
// Tool names may themselves contain double underscores.
expect(routeNamespacedTool('mcp__todos__weird__tool', keys)).toEqual({ serverKey: 'todos', toolName: 'weird__tool' });
expect(routeNamespacedTool('mcp__unknown__x', keys)).toBeUndefined();
expect(routeNamespacedTool('not-namespaced', keys)).toBeUndefined();
});
});
describe('content conversion', () => {
it('narrows every content block type', () => {
expect(contentBlockToParts({ type: 'text', text: 'hi' })).toEqual([{ type: 'text', text: 'hi' }]);
expect(contentBlockToParts({ type: 'image', data: 'abc', mimeType: 'image/png' })).toEqual([
{ type: 'image', mimeType: 'image/png', data: 'abc' }
]);
expect(contentBlockToParts({ type: 'audio', data: 'abc', mimeType: 'audio/wav' })[0]?.type).toBe('text');
expect(contentBlockToParts({ type: 'resource_link', uri: 'todos://board', name: 'board' })[0]).toMatchObject({ type: 'text' });
expect(contentBlockToParts({ type: 'resource', resource: { uri: 'todos://board', text: 'open: 3' } })[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('open: 3')
});
expect(
contentBlockToParts({
type: 'resource',
resource: { uri: 'todos://blob', blob: 'aGk=', mimeType: 'application/octet-stream' }
})[0]
).toMatchObject({ type: 'text', text: expect.stringContaining('binary resource') });
});
it('returns a placeholder for empty tool results and surfaces isError separately', () => {
expect(toolResultToParts({ content: [] })).toEqual([{ type: 'text', text: '(tool returned no content)' }]);
});
it('caps injected content and labels resource context with provenance', () => {
expect(truncate('abc', 2)).toContain('[truncated 1 characters');
const context = resourceToContextText('todos', 'todos://board', { contents: [{ uri: 'todos://board', text: 'open: 3' }] });
expect(context).toContain('<attached-resource server="todos" uri="todos://board">');
expect(context).toContain('open: 3');
const binary = resourceToContextText('todos', 'todos://blob', { contents: [{ uri: 'todos://blob', blob: 'aGVsbG8=' }] });
expect(binary).toContain('[binary content');
});
it('strips ANSI escapes from server-provided text', () => {
expect(stripAnsi('red plain')).toBe('red plain');
});
});
describe('config parsing', () => {
it('accepts stdio and http entries and interpolates environment variables', () => {
const config = parseConfig(
JSON.stringify({
mcpServers: {
todos: { command: 'npx', args: ['-y', 'tsx', 'server.ts'], env: { API_KEY: '${TEST_TOKEN}' } },
remote: { url: 'https://example.com/mcp', headers: { Authorization: 'Bearer ${TEST_TOKEN}' } }
}
}),
{ TEST_TOKEN: 'sekret' }
);
const todos = config.mcpServers.todos;
const remote = config.mcpServers.remote;
expect(todos && !isHttpServer(todos) && todos.env?.API_KEY).toBe('sekret');
expect(remote && isHttpServer(remote) && remote.headers?.Authorization).toBe('Bearer sekret');
});
it('rejects entries that are neither stdio nor http', () => {
expect(() => parseConfig(JSON.stringify({ mcpServers: { broken: { nope: true } } }))).toThrow();
});
it('leaves unknown ${VAR} references empty', () => {
expect(interpolateEnv('Bearer ${MISSING}', {})).toBe('Bearer ');
});
it('builds a config from ad-hoc --server targets', () => {
const config = configFromTargets(['https://mcp.linear.app/mcp', 'npx -y tsx server.ts']);
expect(config.mcpServers['linear']).toEqual({ url: 'https://mcp.linear.app/mcp' });
expect(config.mcpServers['server']).toEqual({ command: 'npx', args: ['-y', 'tsx', 'server.ts'] });
});
it('rejects an empty --server list', () => {
expect(() => configFromTargets([])).toThrow();
});
});

View file

@ -0,0 +1,168 @@
import type Anthropic from '@anthropic-ai/sdk';
import type { GenerateContentResponse } from '@google/genai';
import type OpenAI from 'openai';
import { describe, expect, it } from 'vitest';
import { fromAnthropicResponse, toAnthropicRequest } from '../providers/anthropic';
import { fromGeminiResponse, toGeminiRequest } from '../providers/gemini';
import { fromOpenAIResponse, toOpenAIRequest } from '../providers/openai';
import type { ChatMessage, GenerateRequest } from '../providers/provider';
import { ScriptedProvider } from '../providers/scripted';
const TOOL_SCHEMA = { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] };
const conversation: ChatMessage[] = [
{ role: 'user', content: [{ type: 'text', text: 'add a task' }] },
{
role: 'assistant',
content: [{ type: 'text', text: 'adding it' }],
toolCalls: [
{ id: 'call_1', name: 'mcp__todos__add_task', arguments: { title: 'Write the report' } },
{ id: 'call_2', name: 'mcp__todos__list_tasks', arguments: {} }
]
},
{ role: 'tool', toolCallId: 'call_1', toolName: 'mcp__todos__add_task', content: [{ type: 'text', text: 'Added t1' }] },
{ role: 'tool', toolCallId: 'call_2', toolName: 'mcp__todos__list_tasks', content: [{ type: 'text', text: 'boom' }], isError: true }
];
const request: GenerateRequest = {
system: 'be helpful',
messages: conversation,
tools: [{ name: 'mcp__todos__add_task', description: 'Add a task', inputSchema: TOOL_SCHEMA }],
maxTokens: 256
};
describe('anthropic mapping', () => {
it('builds a Messages API request with namespaced tools, tool_use blocks, and merged tool_result messages', () => {
const params = toAnthropicRequest(request, 'claude-test');
expect(params.model).toBe('claude-test');
expect(params.system).toBe('be helpful');
expect(params.tools?.[0]).toMatchObject({ name: 'mcp__todos__add_task', input_schema: { type: 'object' } });
expect(params.messages).toHaveLength(3);
const assistant = params.messages[1];
expect(assistant?.role).toBe('assistant');
const assistantBlocks = assistant?.content as Anthropic.ContentBlockParam[];
expect(assistantBlocks.filter(block => block.type === 'tool_use')).toHaveLength(2);
// Both tool results (parallel calls) must land in ONE user message.
const toolResults = params.messages[2];
expect(toolResults?.role).toBe('user');
const blocks = toolResults?.content as Anthropic.ToolResultBlockParam[];
expect(blocks.map(block => block.type)).toEqual(['tool_result', 'tool_result']);
expect(blocks[0]?.is_error).toBe(false);
expect(blocks[1]?.is_error).toBe(true);
});
it('parses text, tool calls, and stop reasons from a response', () => {
const response = {
id: 'msg_1',
type: 'message',
role: 'assistant',
model: 'claude-test',
content: [
{ type: 'text', text: 'on it', citations: null },
{ type: 'tool_use', id: 'call_9', name: 'mcp__todos__add_task', input: { title: 'x' } }
],
stop_reason: 'tool_use',
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 }
// The fixture only carries the fields the mapping reads.
} as unknown as Anthropic.Message;
const result = fromAnthropicResponse(response);
expect(result.text).toBe('on it');
expect(result.toolCalls).toEqual([{ id: 'call_9', name: 'mcp__todos__add_task', arguments: { title: 'x' } }]);
expect(result.stopReason).toBe('tool_use');
});
});
describe('openai mapping', () => {
it('builds a Chat Completions request with function tools, tool_calls, and tool-role results', () => {
const params = toOpenAIRequest(request, 'gpt-test');
expect(params.model).toBe('gpt-test');
expect(params.messages[0]).toEqual({ role: 'system', content: 'be helpful' });
expect(params.tools?.[0]).toMatchObject({ type: 'function', function: { name: 'mcp__todos__add_task', parameters: TOOL_SCHEMA } });
const assistant = params.messages.find(message => message.role === 'assistant');
expect(assistant && 'tool_calls' in assistant && assistant.tool_calls).toHaveLength(2);
const toolMessages = params.messages.filter(message => message.role === 'tool');
expect(toolMessages).toHaveLength(2);
expect(toolMessages[1]?.content).toContain('[tool error]');
});
it('parses tool calls (including malformed JSON arguments) from a response', () => {
const response = {
id: 'chatcmpl-1',
object: 'chat.completion',
created: 0,
model: 'gpt-test',
choices: [
{
index: 0,
finish_reason: 'tool_calls',
logprobs: null,
message: {
role: 'assistant',
content: null,
refusal: null,
tool_calls: [
{ id: 'a', type: 'function', function: { name: 'mcp__todos__add_task', arguments: '{"title":"x"}' } },
{ id: 'b', type: 'function', function: { name: 'mcp__todos__list_tasks', arguments: 'not json' } }
]
}
}
]
} as unknown as OpenAI.Chat.Completions.ChatCompletion;
const result = fromOpenAIResponse(response);
expect(result.toolCalls).toEqual([
{ id: 'a', name: 'mcp__todos__add_task', arguments: { title: 'x' } },
{ id: 'b', name: 'mcp__todos__list_tasks', arguments: {} }
]);
expect(result.stopReason).toBe('tool_use');
});
});
describe('gemini mapping', () => {
it('passes MCP JSON Schema through and maps tool results to functionResponse parts', () => {
const params = toGeminiRequest(request, 'gemini-test');
expect(params.model).toBe('gemini-test');
const config = params.config;
expect(config?.systemInstruction).toBe('be helpful');
expect(config?.tools?.[0]).toMatchObject({
functionDeclarations: [{ name: 'mcp__todos__add_task', parametersJsonSchema: TOOL_SCHEMA }]
});
const contents = params.contents as Array<{ role?: string; parts?: Array<Record<string, unknown>> }>;
expect(contents).toHaveLength(3);
expect(contents[1]?.role).toBe('model');
expect(contents[1]?.parts?.some(part => 'functionCall' in part)).toBe(true);
// Results for parallel function calls must share one user turn.
expect(contents[2]?.parts?.[0]).toMatchObject({ functionResponse: { name: 'mcp__todos__add_task' } });
expect(contents[2]?.parts?.[1]).toMatchObject({ functionResponse: { response: { isError: true } } });
});
it('parses text and function calls from a response, generating ids when missing', () => {
const response = {
candidates: [{ content: { role: 'model', parts: [{ text: 'done' }] }, finishReason: 'STOP' }],
functionCalls: [{ name: 'mcp__todos__add_task', args: { title: 'x' } }],
text: 'done'
} as unknown as GenerateContentResponse;
const result = fromGeminiResponse(response, 'gemini-test');
expect(result.text).toBe('done');
expect(result.toolCalls).toEqual([{ id: 'call_1', name: 'mcp__todos__add_task', arguments: { title: 'x' } }]);
expect(result.stopReason).toBe('tool_use');
});
});
describe('scripted provider', () => {
it('replays turns in order and reports leftovers', async () => {
const provider = new ScriptedProvider([{ text: 'one' }, { toolCalls: [{ id: 'c', name: 't', arguments: {} }] }]);
const first = await provider.generate({ messages: [] });
expect(first.text).toBe('one');
const second = await provider.generate({ messages: [] });
expect(second.stopReason).toBe('tool_use');
expect(provider.remaining).toBe(0);
const exhausted = await provider.generate({ messages: [] });
expect(exhausted.text).toContain('no turns left');
});
});