feat(examples): add a reference host/server pair — cli-client + todos-server (#2380)
This commit is contained in:
parent
1823aae891
commit
ba27d201e1
4 changed files with 4698 additions and 25 deletions
159
examples/cli-client/test/auth.test.ts
Normal file
159
examples/cli-client/test/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
examples/cli-client/test/forms.test.ts
Normal file
64
examples/cli-client/test/forms.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
96
examples/cli-client/test/host.test.ts
Normal file
96
examples/cli-client/test/host.test.ts
Normal 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('[31mred[0m 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();
|
||||
});
|
||||
});
|
||||
168
examples/cli-client/test/providers.test.ts
Normal file
168
examples/cli-client/test/providers.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue