Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### CLI

- Add per-server Streamable HTTP paths for `mcporter serve` at `/mcp/<server>`, 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 <unknown-server>` failures while preserving aggregate list health checks by default. (Issue #203, thanks @theo674)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <port>` 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 <port>` to serve Streamable HTTP on localhost at `/mcp`. HTTP mode also exposes `/mcp/<server>` 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
Expand Down
7 changes: 5 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<server>` 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 <port>` – serve MCP Streamable HTTP on `/mcp`, bound to
`127.0.0.1` by default.
- `--http <port>` – serve MCP Streamable HTTP on `/mcp` and
`/mcp/<server>`, bound to `127.0.0.1` by default.
- `--host <host>` – override the HTTP bind host when you intentionally need a
non-local listener.
- `--servers <csv>` – expose only the listed keep-alive server names.
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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
Expand Down
2 changes: 1 addition & 1 deletion src/cli/serve-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Expose daemon-managed keep-alive MCP servers as one MCP server.
Flags:
--servers <csv> Restrict the bridge to the listed keep-alive server names.
--stdio Serve MCP over stdio (default).
--http <port> Serve MCP Streamable HTTP on /mcp.
--http <port> Serve MCP Streamable HTTP on /mcp and /mcp/<server>.
--host <host> Host for --http (default: ${DEFAULT_SERVE_HTTP_HOST}).`);
}

Expand Down
41 changes: 34 additions & 7 deletions src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -90,17 +108,24 @@ 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 },
{
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.',
}
);

Expand All @@ -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),
});
Expand All @@ -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}'.`);
}
Expand Down
13 changes: 13 additions & 0 deletions tests/cli-help-shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<server>'));
expect(process.exitCode).toBe(0);
});
});
114 changes: 114 additions & 0 deletions tests/serve.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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/<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();
});
});
}
});
});
Loading