From 2c04671b92f98e1322c785b4f8473d1e72c4e23a Mon Sep 17 00:00:00 2001 From: zm2231 <25645999+zm2231@users.noreply.github.com> Date: Sat, 30 May 2026 19:50:58 -0400 Subject: [PATCH] feat(serve): expose per-server endpoints at /mcp/ with unprefixed tools Add per-server HTTP routes alongside the aggregate /mcp endpoint. /mcp/ 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. --- src/serve.ts | 41 +++++++++++++++--- tests/serve.test.ts | 101 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/serve.ts b/src/serve.ts index 797cef3..6d774eb 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -21,6 +21,7 @@ export interface ServeOptions { readonly runtime: Pick; 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 { export async function serveHttp(options: ServeHttpOptions): Promise { 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 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}'.`); } diff --git a/tests/serve.test.ts b/tests/serve.test.ts index e1c5b7b..50b478f 100644 --- a/tests/serve.test.ts +++ b/tests/serve.test.ts @@ -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/ 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((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((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((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); });