feat(serve): expose per-server endpoints at /mcp/<server> with unprefixed tools
Add per-server HTTP routes alongside the aggregate /mcp endpoint. /mcp/<server> serves a single server's tools under their original (unprefixed) names so the server can be registered under its own client key, while /mcp keeps the server__tool namespacing. Unknown servers 404; malformed paths 400.
This commit is contained in:
parent
14ff39a59b
commit
2c04671b92
41
src/serve.ts
41
src/serve.ts
@ -21,6 +21,7 @@ export interface ServeOptions {
|
||||
readonly runtime: Pick<Runtime, 'listTools' | 'callTool'>;
|
||||
readonly definitions: readonly ServerDefinition[];
|
||||
readonly servers?: readonly string[];
|
||||
readonly bare?: boolean;
|
||||
}
|
||||
|
||||
export interface ServeStdioOptions extends ServeOptions {}
|
||||
@ -53,11 +54,28 @@ export async function serveStdio(options: ServeStdioOptions): Promise<void> {
|
||||
export async function serveHttp(options: ServeHttpOptions): Promise<http.Server> {
|
||||
const httpServer = http.createServer((request, response) => {
|
||||
const url = new URL(request.url ?? '/', `http://${DEFAULT_SERVE_HTTP_HOST}`);
|
||||
if (url.pathname !== '/mcp') {
|
||||
let bridgeOptions: ServeOptions;
|
||||
if (url.pathname === '/mcp') {
|
||||
bridgeOptions = options;
|
||||
} else if (url.pathname.startsWith('/mcp/')) {
|
||||
let only: string;
|
||||
try {
|
||||
only = decodeURIComponent(url.pathname.slice('/mcp/'.length));
|
||||
} catch {
|
||||
response.writeHead(400).end('Bad request');
|
||||
return;
|
||||
}
|
||||
const known = selectServedServers(options.definitions, options.servers).some((served) => served.name === only);
|
||||
if (!known) {
|
||||
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end(`Unknown server '${only}'`);
|
||||
return;
|
||||
}
|
||||
bridgeOptions = { ...options, servers: [only], bare: true };
|
||||
} else {
|
||||
response.writeHead(404).end('Not found');
|
||||
return;
|
||||
}
|
||||
const bridgeServer = createBridgeServer(options);
|
||||
const bridgeServer = createBridgeServer(bridgeOptions);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
@ -90,9 +108,14 @@ export async function serveHttp(options: ServeHttpOptions): Promise<http.Server>
|
||||
|
||||
export function createBridgeServer(options: ServeOptions): McpServer {
|
||||
const servedServers = selectServedServers(options.definitions, options.servers);
|
||||
if (servedServers.length === 0) {
|
||||
const [firstServed] = servedServers;
|
||||
if (!firstServed) {
|
||||
throw new Error('No keep-alive MCP servers are available to serve.');
|
||||
}
|
||||
const bare = options.bare === true;
|
||||
if (bare && servedServers.length !== 1) {
|
||||
throw new Error('Bare serve mode requires exactly one served server.');
|
||||
}
|
||||
|
||||
const server = new McpServer(
|
||||
{ name: 'mcporter-serve', version: MCPORTER_VERSION },
|
||||
@ -100,7 +123,9 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
} satisfies ServerCapabilities,
|
||||
instructions: 'MCPorter bridge exposing daemon-managed MCP servers. Tool names are namespaced as server__tool.',
|
||||
instructions: bare
|
||||
? `MCPorter bridge exposing the '${firstServed.name}' server.`
|
||||
: 'MCPorter bridge exposing daemon-managed MCP servers. Tool names are namespaced as server__tool.',
|
||||
}
|
||||
);
|
||||
|
||||
@ -119,8 +144,8 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
||||
|
||||
for (const tool of listed) {
|
||||
tools.push({
|
||||
name: encodeToolName(served.name, tool.name),
|
||||
description: describeTool(served.name, tool.description),
|
||||
name: bare ? tool.name : encodeToolName(served.name, tool.name),
|
||||
description: bare ? tool.description : describeTool(served.name, tool.description),
|
||||
inputSchema: normalizeInputSchema(tool.inputSchema),
|
||||
outputSchema: normalizeOutputSchema(tool.outputSchema),
|
||||
});
|
||||
@ -130,7 +155,9 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
||||
});
|
||||
|
||||
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const target = decodeToolName(request.params.name, servedServers);
|
||||
const target = bare
|
||||
? { server: firstServed.name, tool: request.params.name }
|
||||
: decodeToolName(request.params.name, servedServers);
|
||||
if (!target) {
|
||||
throw new McpError(ErrorCode.InvalidParams, `Unknown bridged tool '${request.params.name}'.`);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
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';
|
||||
@ -295,4 +296,104 @@ describe('mcporter serve bridge', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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> and 404s an unknown server', 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 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/alpha`));
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.map((tool) => tool.name)).toEqual(['ping']);
|
||||
await expect(client.callTool({ name: '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);
|
||||
} finally {
|
||||
await client.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();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user