144 lines
4.0 KiB
TypeScript
144 lines
4.0 KiB
TypeScript
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||
|
|
import {
|
||
|
|
createMcpTools,
|
||
|
|
formatMcpToolResult,
|
||
|
|
sanitizeMcpToolName,
|
||
|
|
type IMcpClientLike,
|
||
|
|
} from '../ts_mcp/index.js';
|
||
|
|
|
||
|
|
interface IFakeMcpClientState {
|
||
|
|
calls: Array<{ name: string; arguments?: Record<string, unknown> }>;
|
||
|
|
closed: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
const createFakeMcpClient = (
|
||
|
|
toolName: string,
|
||
|
|
responsePrefix: string,
|
||
|
|
state: IFakeMcpClientState,
|
||
|
|
): IMcpClientLike => ({
|
||
|
|
listTools: async () => ({
|
||
|
|
tools: [
|
||
|
|
{
|
||
|
|
name: toolName,
|
||
|
|
description: `Runs ${toolName}`,
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
value: { type: 'string' },
|
||
|
|
},
|
||
|
|
required: ['value'],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
} as any),
|
||
|
|
callTool: async (params) => {
|
||
|
|
state.calls.push(params);
|
||
|
|
return {
|
||
|
|
content: [
|
||
|
|
{
|
||
|
|
type: 'text',
|
||
|
|
text: `${responsePrefix}: ${String(params.arguments?.value ?? '')}`,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
structuredContent: {
|
||
|
|
ok: true,
|
||
|
|
},
|
||
|
|
} as any;
|
||
|
|
},
|
||
|
|
close: async () => {
|
||
|
|
state.closed = true;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('sanitizeMcpToolName should produce AI SDK-safe tool names', async () => {
|
||
|
|
expect(sanitizeMcpToolName('read file/path')).toEqual('read_file_path');
|
||
|
|
expect(sanitizeMcpToolName('123-start')).toEqual('_123-start');
|
||
|
|
expect(sanitizeMcpToolName('***', 'fallback')).toEqual('fallback');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createMcpTools should expose prefixed tools for multiple MCP servers', async () => {
|
||
|
|
const firstState: IFakeMcpClientState = { calls: [], closed: false };
|
||
|
|
const secondState: IFakeMcpClientState = { calls: [], closed: false };
|
||
|
|
|
||
|
|
const mcpTools = await createMcpTools({
|
||
|
|
servers: {
|
||
|
|
'server one': {
|
||
|
|
type: 'client',
|
||
|
|
client: createFakeMcpClient('echo tool', 'first', firstState),
|
||
|
|
},
|
||
|
|
'server/two': {
|
||
|
|
type: 'client',
|
||
|
|
client: createFakeMcpClient('echo tool', 'second', secondState),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(Object.keys(mcpTools.tools).sort()).toEqual([
|
||
|
|
'server_one__echo_tool',
|
||
|
|
'server_two__echo_tool',
|
||
|
|
]);
|
||
|
|
expect(mcpTools.toolNameMap.server_one__echo_tool).toEqual({
|
||
|
|
serverName: 'server one',
|
||
|
|
toolName: 'echo tool',
|
||
|
|
});
|
||
|
|
|
||
|
|
const output = await (mcpTools.tools.server_one__echo_tool as any).execute({ value: 'hello' });
|
||
|
|
expect(output).toInclude('first: hello');
|
||
|
|
expect(output).toInclude('Structured content');
|
||
|
|
expect(firstState.calls).toEqual([
|
||
|
|
{
|
||
|
|
name: 'echo tool',
|
||
|
|
arguments: { value: 'hello' },
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
await mcpTools.close();
|
||
|
|
expect(firstState.closed).toBeTrue();
|
||
|
|
expect(secondState.closed).toBeTrue();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('createMcpTools should support paginated tool discovery and custom formatting', async () => {
|
||
|
|
const calls: Array<{ name: string; arguments?: Record<string, unknown> }> = [];
|
||
|
|
const client: IMcpClientLike = {
|
||
|
|
listTools: async (params) => ({
|
||
|
|
tools: [
|
||
|
|
{
|
||
|
|
name: params?.cursor ? 'second tool' : 'first tool',
|
||
|
|
inputSchema: { type: 'object' },
|
||
|
|
},
|
||
|
|
],
|
||
|
|
nextCursor: params?.cursor ? undefined : 'next',
|
||
|
|
} as any),
|
||
|
|
callTool: async (params) => {
|
||
|
|
calls.push(params);
|
||
|
|
return { content: [{ type: 'text', text: 'ok' }] } as any;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const mcpTools = await createMcpTools({
|
||
|
|
servers: {
|
||
|
|
only: { type: 'client', client },
|
||
|
|
},
|
||
|
|
formatResult: (_result, context) => context.exposedToolName,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(Object.keys(mcpTools.tools).sort()).toEqual(['first_tool', 'second_tool']);
|
||
|
|
const output = await (mcpTools.tools.second_tool as any).execute('ignored');
|
||
|
|
expect(output).toEqual('second_tool');
|
||
|
|
expect(calls).toEqual([
|
||
|
|
{
|
||
|
|
name: 'second tool',
|
||
|
|
arguments: {},
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('formatMcpToolResult should handle MCP result shapes', async () => {
|
||
|
|
expect(formatMcpToolResult({ toolResult: { ok: true } } as any)).toEqual({ ok: true });
|
||
|
|
expect(formatMcpToolResult({ content: [{ type: 'text', text: 'failed' }], isError: true } as any)).toInclude(
|
||
|
|
'MCP tool returned an error',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
export default tap.start();
|