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:
zm2231 2026-05-30 19:50:58 -04:00 committed by Peter Steinberger
parent 14ff39a59b
commit 2c04671b92
2 changed files with 135 additions and 7 deletions

View File

@ -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}'.`);
}

View File

@ -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();
});
});
}
});
});