mcporter/tests/serve.test.ts
2026-06-08 12:16:38 -07:00

413 lines
15 KiB
TypeScript

import http from 'node:http';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { createKeepAliveRuntime } from '../src/daemon/runtime-wrapper.js';
import type { Runtime } from '../src/runtime.js';
import { createBridgeServer, decodeToolName, encodeToolName, selectServedServers, serveHttp } from '../src/serve.js';
const definitions: ServerDefinition[] = [
{
name: 'alpha',
description: 'keep alive server',
command: { kind: 'http', url: new URL('https://alpha.example.com') },
lifecycle: { mode: 'keep-alive' },
source: { kind: 'local', path: '/tmp' },
},
{
name: 'alpha-long',
description: 'keep alive server',
command: { kind: 'http', url: new URL('https://alpha-long.example.com') },
lifecycle: { mode: 'keep-alive' },
source: { kind: 'local', path: '/tmp' },
},
{
name: 'beta',
description: 'ephemeral server',
command: { kind: 'http', url: new URL('https://beta.example.com') },
source: { kind: 'local', path: '/tmp' },
},
];
describe('mcporter serve bridge', () => {
it('selects only keep-alive servers and validates explicit filters', () => {
expect(selectServedServers(definitions).map((entry) => entry.name)).toEqual(['alpha', 'alpha-long']);
expect(selectServedServers(definitions, ['alpha']).map((entry) => entry.name)).toEqual(['alpha']);
expect(() => selectServedServers(definitions, ['beta'])).toThrow("Server 'beta' is not configured for keep-alive");
});
it('encodes and decodes namespaced tool names with longest-prefix matching', () => {
expect(encodeToolName('alpha', 'ping')).toBe('alpha__ping');
expect(decodeToolName('alpha-long__tool%5F%5Fwith_separator', [{ name: 'alpha' }, { name: 'alpha-long' }])).toEqual(
{
server: 'alpha-long',
tool: 'tool__with_separator',
}
);
});
it('escapes namespaced tool parts to avoid server/tool collisions', () => {
const first = encodeToolName('alpha', 'beta__ping');
const second = encodeToolName('alpha__beta', 'ping');
expect(first).toBe('alpha__beta%5F%5Fping');
expect(second).toBe('alpha%5F%5Fbeta__ping');
expect(first).not.toBe(second);
expect(decodeToolName(first, [{ name: 'alpha' }, { name: 'alpha__beta' }])).toEqual({
server: 'alpha',
tool: 'beta__ping',
});
expect(decodeToolName(second, [{ name: 'alpha' }, { name: 'alpha__beta' }])).toEqual({
server: 'alpha__beta',
tool: 'ping',
});
});
it('preserves ordinary underscores in server and tool names', () => {
expect(encodeToolName('memory', 'create_entities')).toBe('memory__create_entities');
expect(encodeToolName('alpha_', '_ping')).toBe('alpha%5F__%5Fping');
expect(decodeToolName('alpha%5F__%5Fping', [{ name: 'alpha' }, { name: 'alpha_' }])).toEqual({
server: 'alpha_',
tool: '_ping',
});
});
it('escapes boundary underscores so namespaced tools stay injective', () => {
const first = encodeToolName('alpha', '_ping');
const second = encodeToolName('alpha_', 'ping');
expect(first).toBe('alpha__%5Fping');
expect(second).toBe('alpha%5F__ping');
expect(first).not.toBe(second);
expect(decodeToolName(first, [{ name: 'alpha' }, { name: 'alpha_' }])).toEqual({
server: 'alpha',
tool: '_ping',
});
expect(decodeToolName(second, [{ name: 'alpha' }, { name: 'alpha_' }])).toEqual({
server: 'alpha_',
tool: 'ping',
});
});
it('exposes daemon tools through a single MCP server', async () => {
const runtime = {
listTools: vi.fn().mockImplementation(async (server: string) => [
{
name: 'ping',
description: `${server} ping`,
inputSchema: {
type: 'object',
properties: { value: { type: 'number' } },
required: ['value'],
},
},
]),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'pong' }],
}),
};
const bridge = createBridgeServer({
runtime,
definitions,
servers: ['alpha'],
});
const client = new Client({ name: 'test-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([bridge.connect(serverTransport), client.connect(clientTransport)]);
const tools = await client.listTools();
expect(tools.tools).toEqual([
expect.objectContaining({
name: 'alpha__ping',
description: '[alpha] alpha ping',
inputSchema: expect.objectContaining({ required: ['value'] }),
}),
]);
expect(runtime.listTools).toHaveBeenCalledWith('alpha', {
includeSchema: true,
autoAuthorize: true,
});
await expect(client.callTool({ name: 'alpha__ping', arguments: { value: 1 } })).resolves.toEqual({
content: [{ type: 'text', text: 'pong' }],
});
expect(runtime.callTool).toHaveBeenCalledWith('alpha', 'ping', {
args: { value: 1 },
});
await client.close();
await bridge.close();
});
it('routes bridged keep-alive tool traffic through the daemon runtime wrapper', async () => {
const baseRuntime = {
listServers: () => definitions.map((definition) => definition.name),
getDefinitions: () => definitions,
getDefinition: (server: string) => {
const definition = definitions.find((entry) => entry.name === server);
if (!definition) {
throw new Error(`Unknown server ${server}`);
}
return definition;
},
registerDefinition: vi.fn(),
listTools: vi.fn().mockResolvedValue([{ name: 'local-tool' }]),
callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'local' }] }),
listResources: vi.fn(),
readResource: vi.fn(),
connect: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
} satisfies Runtime;
const daemon = {
listTools: vi.fn().mockResolvedValue([
{
name: 'ping',
description: 'daemon ping',
inputSchema: { type: 'object' },
},
]),
callTool: vi
.fn()
.mockRejectedValueOnce(new Error('daemon transport died'))
.mockResolvedValueOnce({
content: [{ type: 'text', text: 'daemon pong' }],
}),
listResources: vi.fn(),
readResource: vi.fn(),
closeServer: vi.fn().mockResolvedValue(undefined),
};
const runtime = createKeepAliveRuntime(baseRuntime, {
daemonClient: daemon as never,
keepAliveServers: new Set(['alpha']),
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const bridge = createBridgeServer({
runtime,
definitions,
servers: ['alpha'],
});
const client = new Client({ name: 'daemon-wrapper-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await Promise.all([bridge.connect(serverTransport), client.connect(clientTransport)]);
await expect(client.listTools()).resolves.toMatchObject({
tools: [{ name: 'alpha__ping', description: '[alpha] daemon ping' }],
});
expect(daemon.listTools).toHaveBeenCalledWith({
server: 'alpha',
includeSchema: true,
autoAuthorize: true,
allowCachedAuth: true,
});
expect(baseRuntime.listTools).not.toHaveBeenCalled();
await expect(client.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
content: [{ type: 'text', text: 'daemon pong' }],
});
expect(daemon.callTool).toHaveBeenCalledTimes(2);
expect(daemon.closeServer).toHaveBeenCalledWith({ server: 'alpha' });
expect(baseRuntime.callTool).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Restarting 'alpha'"));
} finally {
errorSpy.mockRestore();
await client.close().catch(() => {});
await bridge.close().catch(() => {});
}
});
it('serves the bridge over Streamable HTTP', async () => {
const runtime = {
listTools: vi.fn().mockResolvedValue([
{
name: 'ping',
inputSchema: { type: 'object' },
},
]),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'pong-http' }],
}),
};
const httpServer = await serveHttp({
runtime,
definitions,
servers: ['alpha'],
port: 0,
});
const address = httpServer.address();
if (!address || typeof address !== 'object') {
throw new Error('Expected test HTTP server to listen on a TCP port.');
}
expect(address.address).toBe('127.0.0.1');
const client = new Client({ name: 'test-http-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`));
try {
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toEqual(['alpha__ping']);
await expect(client.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
content: [{ type: 'text', text: 'pong-http' }],
});
} finally {
await client.close().catch(() => {});
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
it('returns 404 for paths outside the MCP endpoint', async () => {
const runtime = {
listTools: vi.fn(),
callTool: vi.fn(),
};
const httpServer = await serveHttp({
runtime,
definitions,
servers: ['alpha'],
port: 0,
});
const address = httpServer.address();
if (!address || typeof address !== 'object') {
throw new Error('Expected test HTTP server to listen on a TCP port.');
}
try {
const response = await fetch(`http://127.0.0.1:${address.port}/mcp-extra`);
expect(response.status).toBe(404);
} finally {
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
it('exposes a single server unprefixed in bare mode', async () => {
const runtime = {
listTools: vi
.fn()
.mockImplementation(async (server: string) => [
{ name: 'ping', description: `${server} ping`, inputSchema: { type: 'object' } },
]),
callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'pong' }] }),
};
const bridge = createBridgeServer({ runtime, definitions, servers: ['alpha'], bare: true });
const client = new Client({ name: 'test-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([bridge.connect(serverTransport), client.connect(clientTransport)]);
const tools = await client.listTools();
expect(tools.tools).toEqual([expect.objectContaining({ name: 'ping', description: 'alpha ping' })]);
await expect(client.callTool({ name: 'ping', arguments: { value: 1 } })).resolves.toEqual({
content: [{ type: 'text', text: 'pong' }],
});
expect(runtime.callTool).toHaveBeenCalledWith('alpha', 'ping', { args: { value: 1 } });
await client.close();
await bridge.close();
});
it('rejects bare mode unless exactly one server is served', () => {
const runtime = { listTools: vi.fn(), callTool: vi.fn() };
expect(() => createBridgeServer({ runtime, definitions, bare: true })).toThrow(
'Bare serve mode requires exactly one served server.'
);
});
it('serves a single server unprefixed over /mcp/<server> without changing aggregate /mcp', async () => {
const runtime = {
listTools: vi.fn().mockResolvedValue([{ name: 'ping', inputSchema: { type: 'object' } }]),
callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'pong-http' }] }),
};
const httpServer = await serveHttp({ runtime, definitions, servers: ['alpha'], port: 0 });
const address = httpServer.address();
if (!address || typeof address !== 'object') {
throw new Error('Expected test HTTP server to listen on a TCP port.');
}
const perServerClient = new Client({ name: 'test-http-client', version: '1.0.0' });
const perServerTransport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp/alpha`));
const aggregateClient = new Client({ name: 'test-http-aggregate-client', version: '1.0.0' });
const aggregateTransport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`));
try {
await perServerClient.connect(perServerTransport);
const perServerTools = await perServerClient.listTools();
expect(perServerTools.tools.map((tool) => tool.name)).toEqual(['ping']);
await expect(perServerClient.callTool({ name: 'ping', arguments: {} })).resolves.toEqual({
content: [{ type: 'text', text: 'pong-http' }],
});
await aggregateClient.connect(aggregateTransport);
const aggregateTools = await aggregateClient.listTools();
expect(aggregateTools.tools.map((tool) => tool.name)).toEqual(['alpha__ping']);
await expect(aggregateClient.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
content: [{ type: 'text', text: 'pong-http' }],
});
const unknown = await fetch(`http://127.0.0.1:${address.port}/mcp/nope`);
expect(unknown.status).toBe(404);
expect(unknown.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(await unknown.text()).toBe("Unknown server 'nope'");
} finally {
await perServerClient.close().catch(() => {});
await aggregateClient.close().catch(() => {});
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
it('returns 400 for a malformed percent-encoded server path', async () => {
const runtime = { listTools: vi.fn(), callTool: vi.fn() };
const httpServer = await serveHttp({ runtime, definitions, servers: ['alpha'], port: 0 });
const address = httpServer.address();
if (!address || typeof address !== 'object') {
throw new Error('Expected test HTTP server to listen on a TCP port.');
}
try {
const status = await new Promise<number>((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port: address.port, path: '/mcp/%E0%A4%A', method: 'GET' },
(res) => {
res.resume();
resolve(res.statusCode ?? 0);
}
);
req.on('error', reject);
req.end();
});
expect(status).toBe(400);
} finally {
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
});