diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c40867..babc642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### CLI +- Add per-server Streamable HTTP paths for `mcporter serve` at `/mcp/`, exposing one keep-alive server with original tool names while preserving aggregate `/mcp` namespacing. (PR #194, thanks @zm2231) - Add `mcporter record` and `mcporter replay` helpers for capturing and replaying MCP JSON-RPC traffic, with server filters and daemon-safe manual env setup. (PR #192, thanks @LDMB123) - Prevent direct daemon starts from rebinding over an already-running healthy daemon, avoiding orphaned keep-alive processes during foreground or launch races. (PR #195, thanks @zm2231) - Return a non-zero exit code for explicit `mcporter list ` failures while preserving aggregate list health checks by default. (Issue #203, thanks @theo674) diff --git a/README.md b/README.md index 56e4f50..214b204 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools - Stop it anytime with `mcporter daemon stop`, pre-warm with `mcporter daemon start`, or bounce it via `mcporter daemon restart` after tweaking configs/env. - All other servers stay ephemeral; add `"lifecycle": "keep-alive"` to a server entry (or set `MCPORTER_KEEPALIVE=name`) when you want the daemon to manage it. You can also set `"lifecycle": "ephemeral"` (or `MCPORTER_DISABLE_KEEPALIVE=name`) to opt out. - The daemon only manages named servers that come from your config/imports. Ad-hoc STDIO/HTTP targets invoked via `--stdio …`, `--http-url …`, or inline function-call syntax remain per-process today; persist them into `config/mcporter.json` (or use `--persist`) if you need them to participate in the shared daemon. -- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http ` to serve Streamable HTTP on localhost at `/mcp`. +- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http ` to serve Streamable HTTP on localhost at `/mcp`. HTTP mode also exposes `/mcp/` for one selected keep-alive server with its original, unprefixed tool names. - Troubleshooting? Run `mcporter daemon start --log` (or `--log-file /tmp/daemon.log`) to tee stdout/stderr into a file, and add `--log-servers chrome-devtools` when you only want call traces for a specific MCP. Per-server configs can also set `"logging": { "daemon": { "enabled": true } }` to force detailed logging for that entry. ## Friendlier Tool Calls diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 19708f2..a9764fa 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -71,14 +71,17 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits - `tools/list` queries the daemon for each selected server and publishes tools as `server__tool`; `tools/call` strips the prefix and routes the call through the daemon. +- In HTTP mode, `/mcp` keeps the aggregate namespaced bridge, while + `/mcp/` exposes one selected keep-alive server with its original, + unprefixed tool names. - Only configured keep-alive servers participate. Add `"lifecycle": "keep-alive"` to a server definition when you want it managed by the daemon. - Flags: - `--stdio` – serve MCP over stdio; this is the default and is the mode to register with Claude Code, Codex, and similar clients. - - `--http ` – serve MCP Streamable HTTP on `/mcp`, bound to - `127.0.0.1` by default. + - `--http ` – serve MCP Streamable HTTP on `/mcp` and + `/mcp/`, bound to `127.0.0.1` by default. - `--host ` – override the HTTP bind host when you intentionally need a non-local listener. - `--servers ` – expose only the listed keep-alive server names. diff --git a/docs/index.md b/docs/index.md index 12f9212..6760d47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,7 +42,7 @@ mcporter leans into the **code-execution-with-MCP** pattern Anthropic recommends - **Typed clients.** [`mcporter emit-ts`](emit-ts.md) emits `.d.ts` interfaces or a ready-to-run client wrapping `createServerProxy()` so agents call MCP tools with full TypeScript types. - **Friendly composable API.** [`createServerProxy()`](tool-calling.md) maps tools to camelCase methods, applies JSON-schema defaults, validates required arguments, and returns a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, `.content()` helpers. - **Ad-hoc connections + auto-OAuth.** Point the CLI at any MCP endpoint (HTTP, SSE, stdio) without touching config. Hosted MCPs that need a browser login (Supabase, Vercel, etc.) are auto-detected — `mcporter auth ` promotes the definition to OAuth on the fly. See [Ad-hoc connections](adhoc.md). -- **MCP bridge for agents.** `mcporter serve --stdio` exposes daemon-managed keep-alive servers as one MCP server, with tools namespaced as `server__tool`, so clients can share the same warm daemon-backed transports. +- **MCP bridge for agents.** `mcporter serve` exposes daemon-managed keep-alive servers as one MCP server with namespaced `server__tool` tools, or as per-server HTTP paths that keep original tool names. - **OAuth & stdio ergonomics.** Built-in OAuth caching, token refresh, log tailing, and stdio wrappers — same interface across HTTP, SSE, and stdio transports. ## Built for agents diff --git a/src/cli/serve-command.ts b/src/cli/serve-command.ts index a028ccb..d8d14d5 100644 --- a/src/cli/serve-command.ts +++ b/src/cli/serve-command.ts @@ -93,7 +93,7 @@ Expose daemon-managed keep-alive MCP servers as one MCP server. Flags: --servers Restrict the bridge to the listed keep-alive server names. --stdio Serve MCP over stdio (default). - --http Serve MCP Streamable HTTP on /mcp. + --http Serve MCP Streamable HTTP on /mcp and /mcp/. --host Host for --http (default: ${DEFAULT_SERVE_HTTP_HOST}).`); } 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/cli-help-shortcuts.test.ts b/tests/cli-help-shortcuts.test.ts index bc54c81..00489d6 100644 --- a/tests/cli-help-shortcuts.test.ts +++ b/tests/cli-help-shortcuts.test.ts @@ -45,4 +45,17 @@ describe('mcporter help shortcuts (hidden)', () => { expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(expectSnippet)); expect(process.exitCode).toBe(0); }); + + it.each([ + ['serve', '--help'], + ['serve', 'help'], + ])('prints serve HTTP endpoint help for %j', async (...args) => { + const { runCli } = await cliModulePromise; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCli(args); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('/mcp/')); + expect(process.exitCode).toBe(0); + }); }); diff --git a/tests/serve.test.ts b/tests/serve.test.ts index e1c5b7b..d758c5d 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,117 @@ 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/ 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((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(); + }); + }); + } + }); });