feat: add mcporter serve bridge

This commit is contained in:
zm2231 2026-05-13 20:26:12 -04:00
parent f9f60d7cc4
commit 6879a69f49
10 changed files with 803 additions and 0 deletions

View File

@ -195,6 +195,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`.
- 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

View File

@ -56,6 +56,25 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--json` shortcut for `--output json`.
- `--raw` shortcut for `--output raw`.
## `mcporter serve [--servers a,b,c] [--stdio | --http <port>]`
- Exposes daemon-managed keep-alive servers as one MCP server for clients that
consume MCP over stdio or Streamable HTTP.
- `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.
- 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.
- `--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.
## `mcporter generate-cli`
- Produces a standalone CLI for a single MCP server (optionally bundling or

View File

@ -42,6 +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.
- **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

View File

@ -139,6 +139,21 @@ export async function runCli(argv: string[]): Promise<void> {
return;
}
if (command === 'serve') {
const { handleServeCli, printServeHelp } = await import('./cli/serve-command.js');
if (consumeHelpTokens(args)) {
printServeHelp();
process.exitCode = 0;
return;
}
await handleServeCli(args, {
configPath: configPathResolved,
configExplicit: configResolution.explicit,
rootDir: rootOverride,
});
return;
}
if (command === 'config') {
const { handleConfigCli } = await import('./cli/config-command.js');
await handleConfigCli(
@ -438,6 +453,7 @@ function isExplicitNonCallCommand(command: string): boolean {
command === 'resource' ||
command === 'resources' ||
command === 'daemon' ||
command === 'serve' ||
command === 'config' ||
command === 'emit-ts' ||
command === 'generate-cli' ||

View File

@ -112,6 +112,11 @@ function buildCommandSections(colorize: boolean): string[] {
summary: 'Manage the keep-alive daemon (start | status | stop | restart)',
usage: 'mcporter daemon <subcommand>',
},
{
name: 'serve',
summary: 'Expose daemon-managed keep-alive servers as one MCP server',
usage: 'mcporter serve [--servers a,b,c] [--stdio | --http <port>]',
},
],
},
];

194
src/cli/serve-command.ts Normal file
View File

@ -0,0 +1,194 @@
import { DaemonClient } from '../daemon/client.js';
import { createKeepAliveRuntime } from '../daemon/runtime-wrapper.js';
import { isKeepAliveServer } from '../lifecycle.js';
import { createRuntime } from '../runtime.js';
import { DEFAULT_SERVE_HTTP_HOST, selectServedServers, serveHttp, serveStdio } from '../serve.js';
interface ServeCliOptions {
readonly configPath: string;
readonly configExplicit?: boolean;
readonly rootDir?: string;
}
interface ParsedServeArgs {
readonly mode: 'stdio' | 'http';
readonly port?: number;
readonly host?: string;
readonly servers?: string[];
}
export async function handleServeCli(args: string[], options: ServeCliOptions): Promise<void> {
const parsed = parseServeArgs(args);
const baseRuntime = await createRuntime({
configPath: options.configExplicit ? options.configPath : undefined,
rootDir: options.rootDir,
});
const definitions = baseRuntime.getDefinitions();
const keepAliveServers = new Set(definitions.filter(isKeepAliveServer).map((definition) => definition.name));
let selectedServers: string[];
try {
const servedServers = selectServedServers(definitions, parsed.servers);
selectedServers = servedServers.map((server) => server.name);
if (selectedServers.length === 0) {
throw new Error('No MCP servers are configured for keep-alive; nothing to serve.');
}
} catch (error) {
await baseRuntime.close().catch(() => {});
throw error;
}
const daemonClient = new DaemonClient({
configPath: options.configPath,
configExplicit: options.configExplicit,
rootDir: options.rootDir,
});
const runtime = createKeepAliveRuntime(baseRuntime, {
daemonClient,
keepAliveServers,
});
if (parsed.mode === 'http') {
let server: Awaited<ReturnType<typeof serveHttp>>;
try {
server = await serveHttp({
runtime,
definitions,
servers: selectedServers,
port: parsed.port ?? 0,
host: parsed.host,
});
} catch (error) {
await runtime.close().catch(() => {});
throw error;
}
server.once('close', () => {
void runtime.close().catch(() => {});
});
const address = server.address();
const location =
typeof address === 'object' && address
? `http://${address.address === '::' ? 'localhost' : address.address}:${address.port}/mcp`
: 'listening';
console.error(`MCPorter serve HTTP bridge ${location}`);
return;
}
try {
await serveStdio({
runtime,
definitions,
servers: selectedServers,
});
} finally {
await runtime.close().catch(() => {});
}
}
export function printServeHelp(): void {
console.log(`Usage: mcporter serve [--servers a,b,c] [--stdio | --http <port>]
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.
--host <host> Host for --http (default: ${DEFAULT_SERVE_HTTP_HOST}).`);
}
export function parseServeArgs(args: string[]): ParsedServeArgs {
let mode: 'stdio' | 'http' = 'stdio';
let port: number | undefined;
let host: string | undefined;
let servers: string[] | undefined;
let explicitStdio = false;
let explicitHttp = false;
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (!token) {
continue;
}
if (token === '--stdio') {
explicitStdio = true;
mode = 'stdio';
continue;
}
if (token === '--http') {
explicitHttp = true;
mode = 'http';
const value = args[index + 1];
if (!value) {
throw new Error("Flag '--http' requires a port.");
}
port = parsePort(value);
index += 1;
continue;
}
if (token.startsWith('--http=')) {
explicitHttp = true;
mode = 'http';
port = parsePort(token.slice('--http='.length));
continue;
}
if (token === '--servers') {
const value = args[index + 1];
if (!value) {
throw new Error("Flag '--servers' requires a comma-separated list.");
}
servers = parseServerList(value);
index += 1;
continue;
}
if (token.startsWith('--servers=')) {
servers = parseServerList(token.slice('--servers='.length));
continue;
}
if (token === '--host') {
const value = args[index + 1];
if (!value) {
throw new Error("Flag '--host' requires a value.");
}
host = value;
index += 1;
continue;
}
if (token.startsWith('--host=')) {
host = token.slice('--host='.length);
if (!host) {
throw new Error("Flag '--host' requires a value.");
}
continue;
}
throw new Error(`Unknown serve flag '${token}'.`);
}
if (explicitStdio && explicitHttp) {
throw new Error("Flags '--stdio' and '--http' cannot be used together.");
}
if (host && mode !== 'http') {
throw new Error("Flag '--host' can only be used with '--http'.");
}
return { mode, port, host, servers };
}
function parsePort(value: string): number {
const port = Number(value);
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
throw new Error(`Invalid HTTP port '${value}'.`);
}
return port;
}
function parseServerList(value: string): string[] {
const servers = value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (servers.length === 0) {
throw new Error("Flag '--servers' requires at least one server name.");
}
return servers;
}

212
src/serve.ts Normal file
View File

@ -0,0 +1,212 @@
import http from 'node:http';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
type CallToolResult,
type ListToolsResult,
type ServerCapabilities,
type Tool,
} from '@modelcontextprotocol/sdk/types.js';
import type { ServerDefinition } from './config.js';
import { isKeepAliveServer } from './lifecycle.js';
import type { Runtime } from './runtime.js';
import { MCPORTER_VERSION } from './version.js';
export interface ServeOptions {
readonly runtime: Pick<Runtime, 'listTools' | 'callTool'>;
readonly definitions: readonly ServerDefinition[];
readonly servers?: readonly string[];
}
export interface ServeStdioOptions extends ServeOptions {}
export interface ServeHttpOptions extends ServeOptions {
readonly port: number;
readonly host?: string;
}
interface ServedServer {
readonly name: string;
readonly definition: ServerDefinition;
}
const TOOL_SEPARATOR = '__';
const DEFAULT_OBJECT_SCHEMA = { type: 'object' } as const;
export const DEFAULT_SERVE_HTTP_HOST = '127.0.0.1';
export async function serveStdio(options: ServeStdioOptions): Promise<void> {
const server = createBridgeServer(options);
const transport = new StdioServerTransport();
const closed = new Promise<void>((resolve, reject) => {
transport.onclose = () => resolve();
transport.onerror = (error) => reject(error);
});
await server.connect(transport);
await closed;
}
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') {
response.writeHead(404).end('Not found');
return;
}
const bridgeServer = createBridgeServer(options);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
response.on('close', () => {
void transport.close().catch(() => {});
void bridgeServer.close().catch(() => {});
});
void (async () => {
await bridgeServer.connect(transport);
await transport.handleRequest(request, response);
})().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (!response.headersSent) {
response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
}
response.end(message);
});
});
await new Promise<void>((resolve, reject) => {
httpServer.once('error', reject);
httpServer.listen(options.port, options.host ?? DEFAULT_SERVE_HTTP_HOST, () => {
httpServer.off('error', reject);
resolve();
});
});
return httpServer;
}
export function createBridgeServer(options: ServeOptions): McpServer {
const servedServers = selectServedServers(options.definitions, options.servers);
if (servedServers.length === 0) {
throw new Error('No keep-alive MCP servers are available to serve.');
}
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.',
}
);
server.server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools: Tool[] = [];
for (const served of servedServers) {
const listed = (await options.runtime.listTools(served.name, {
includeSchema: true,
autoAuthorize: true,
})) as Array<{
name: string;
description?: string;
inputSchema?: unknown;
outputSchema?: unknown;
}>;
for (const tool of listed) {
tools.push({
name: encodeToolName(served.name, tool.name),
description: describeTool(served.name, tool.description),
inputSchema: normalizeInputSchema(tool.inputSchema),
outputSchema: normalizeOutputSchema(tool.outputSchema),
});
}
}
return { tools } satisfies ListToolsResult;
});
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const target = decodeToolName(request.params.name, servedServers);
if (!target) {
throw new McpError(ErrorCode.InvalidParams, `Unknown bridged tool '${request.params.name}'.`);
}
const result = await options.runtime.callTool(target.server, target.tool, {
args: request.params.arguments,
});
return result as CallToolResult;
});
return server;
}
export function selectServedServers(
definitions: readonly ServerDefinition[],
requested?: readonly string[]
): ServedServer[] {
const keepAlive = definitions.filter(isKeepAliveServer);
if (!requested || requested.length === 0) {
return keepAlive.map((definition) => ({ name: definition.name, definition }));
}
const byName = new Map(keepAlive.map((definition) => [definition.name, definition]));
return requested.map((name) => {
const definition = byName.get(name);
if (!definition) {
throw new Error(`Server '${name}' is not configured for keep-alive and cannot be served by the daemon bridge.`);
}
return { name, definition };
});
}
export function encodeToolName(server: string, tool: string): string {
return `${server}${TOOL_SEPARATOR}${tool}`;
}
export function decodeToolName(
name: string,
servedServers: readonly Pick<ServedServer, 'name'>[]
): { server: string; tool: string } | undefined {
const sorted = [...servedServers].toSorted((a, b) => b.name.length - a.name.length);
for (const server of sorted) {
const prefix = `${server.name}${TOOL_SEPARATOR}`;
if (name.startsWith(prefix)) {
const tool = name.slice(prefix.length);
if (tool.length > 0) {
return { server: server.name, tool };
}
}
}
return undefined;
}
function describeTool(server: string, description: string | undefined): string | undefined {
if (!description) {
return `Tool from MCPorter server '${server}'.`;
}
return `[${server}] ${description}`;
}
function normalizeInputSchema(schema: unknown): Tool['inputSchema'] {
if (isObjectSchema(schema)) {
return schema;
}
return DEFAULT_OBJECT_SCHEMA;
}
function normalizeOutputSchema(schema: unknown): Tool['outputSchema'] {
if (isObjectSchema(schema)) {
return schema;
}
return undefined;
}
function isObjectSchema(schema: unknown): schema is Tool['inputSchema'] {
if (!schema || typeof schema !== 'object') {
return false;
}
return (schema as { type?: unknown }).type === 'object';
}

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { parseServeArgs } from '../src/cli/serve-command.js';
describe('serve command arguments', () => {
it('defaults to stdio and parses server filters', () => {
expect(parseServeArgs(['--servers', 'alpha,beta'])).toEqual({
mode: 'stdio',
port: undefined,
host: undefined,
servers: ['alpha', 'beta'],
});
});
it('parses streamable HTTP mode', () => {
expect(parseServeArgs(['--http=3210', '--host', 'localhost'])).toEqual({
mode: 'http',
port: 3210,
host: 'localhost',
servers: undefined,
});
});
it('parses equals-form host overrides', () => {
expect(parseServeArgs(['--http', '3210', '--host=0.0.0.0'])).toEqual({
mode: 'http',
port: 3210,
host: '0.0.0.0',
servers: undefined,
});
});
it('rejects invalid ports', () => {
expect(() => parseServeArgs(['--http', 'nope'])).toThrow("Invalid HTTP port 'nope'");
});
it('rejects conflicting stdio and HTTP modes', () => {
expect(() => parseServeArgs(['--stdio', '--http', '3210'])).toThrow(
"Flags '--stdio' and '--http' cannot be used together."
);
});
it('rejects host overrides without HTTP mode', () => {
expect(() => parseServeArgs(['--host', '0.0.0.0'])).toThrow("Flag '--host' can only be used with '--http'.");
});
});

View File

@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const closeMock = vi.fn();
const createRuntimeMock = vi.fn();
const createKeepAliveRuntimeMock = vi.fn();
const daemonClientInstance = { callTool: vi.fn(), listTools: vi.fn(), closeServer: vi.fn() };
const DaemonClientMock = vi.fn();
const serveStdioMock = vi.fn();
const serveHttpMock = vi.fn();
const definitions = [
{
name: 'alpha',
command: { kind: 'http', url: new URL('https://alpha.example.com') },
lifecycle: { mode: 'keep-alive' },
},
];
vi.mock('../src/runtime.js', () => ({
createRuntime: (...args: Parameters<typeof createRuntimeMock>) => createRuntimeMock(...args),
}));
vi.mock('../src/daemon/client.js', () => ({
DaemonClient: DaemonClientMock,
}));
vi.mock('../src/daemon/runtime-wrapper.js', () => ({
createKeepAliveRuntime: (...args: Parameters<typeof createKeepAliveRuntimeMock>) =>
createKeepAliveRuntimeMock(...args),
}));
vi.mock('../src/serve.js', async () => {
const actual = await vi.importActual<typeof import('../src/serve.js')>('../src/serve.js');
return {
...actual,
serveStdio: serveStdioMock,
serveHttp: serveHttpMock,
};
});
const { handleServeCli } = await import('../src/cli/serve-command.js');
describe('serve command runtime wiring', () => {
beforeEach(() => {
closeMock.mockReset().mockResolvedValue(undefined);
createRuntimeMock.mockReset();
createKeepAliveRuntimeMock.mockReset();
DaemonClientMock.mockReset();
serveStdioMock.mockReset().mockResolvedValue(undefined);
serveHttpMock.mockReset();
const baseRuntime = {
getDefinitions: () => definitions,
close: closeMock,
};
const wrappedRuntime = {
listTools: vi.fn(),
callTool: vi.fn(),
close: closeMock,
};
createRuntimeMock.mockResolvedValue(baseRuntime);
createKeepAliveRuntimeMock.mockReturnValue(wrappedRuntime);
DaemonClientMock.mockImplementation(function MockDaemonClient() {
return daemonClientInstance;
});
});
it('wraps configured keep-alive servers with the daemon runtime before serving stdio', async () => {
await handleServeCli(['--servers', 'alpha'], { configPath: '/tmp/config.json', configExplicit: true });
expect(DaemonClientMock).toHaveBeenCalledWith({
configPath: '/tmp/config.json',
configExplicit: true,
rootDir: undefined,
});
expect(createKeepAliveRuntimeMock).toHaveBeenCalledWith(
expect.objectContaining({ getDefinitions: expect.any(Function) }),
{
daemonClient: daemonClientInstance,
keepAliveServers: new Set(['alpha']),
}
);
expect(serveStdioMock).toHaveBeenCalledWith({
runtime: createKeepAliveRuntimeMock.mock.results[0]?.value,
definitions: expect.arrayContaining([expect.objectContaining({ name: 'alpha' })]),
servers: ['alpha'],
});
expect(closeMock).toHaveBeenCalledTimes(1);
});
it('validates selected servers before starting the HTTP listener', async () => {
await expect(
handleServeCli(['--http', '3000', '--servers', 'missing'], {
configPath: '/tmp/config.json',
configExplicit: true,
})
).rejects.toThrow("Server 'missing' is not configured for keep-alive");
expect(serveHttpMock).not.toHaveBeenCalled();
expect(createKeepAliveRuntimeMock).not.toHaveBeenCalled();
expect(closeMock).toHaveBeenCalledTimes(1);
});
it('passes host overrides into the HTTP bridge', async () => {
const httpServer = {
once: vi.fn(),
address: () => ({ address: '0.0.0.0', port: 3000 }),
};
serveHttpMock.mockResolvedValue(httpServer);
await handleServeCli(['--http', '3000', '--host', '0.0.0.0'], {
configPath: '/tmp/config.json',
configExplicit: true,
});
expect(serveHttpMock).toHaveBeenCalledWith(
expect.objectContaining({
host: '0.0.0.0',
port: 3000,
servers: ['alpha'],
})
);
expect(httpServer.once).toHaveBeenCalledWith('close', expect.any(Function));
});
it('closes the runtime when HTTP startup fails', async () => {
serveHttpMock.mockRejectedValue(new Error('listen failed'));
await expect(
handleServeCli(['--http', '3000'], {
configPath: '/tmp/config.json',
configExplicit: true,
})
).rejects.toThrow('listen failed');
expect(closeMock).toHaveBeenCalledTimes(1);
});
});

173
tests/serve.test.ts Normal file
View File

@ -0,0 +1,173 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { createBridgeServer, decodeToolName, encodeToolName, selectServedServers, serveHttp } from '../src/serve.js';
const definitions: ServerDefinition[] = [
{
name: 'alpha',
description: 'keep alive server',
command: { kind: 'http', url: new URL('https://alpha.example.com') },
lifecycle: { mode: 'keep-alive' },
source: { kind: 'local', path: '/tmp' },
},
{
name: 'alpha-long',
description: 'keep alive server',
command: { kind: 'http', url: new URL('https://alpha-long.example.com') },
lifecycle: { mode: 'keep-alive' },
source: { kind: 'local', path: '/tmp' },
},
{
name: 'beta',
description: 'ephemeral server',
command: { kind: 'http', url: new URL('https://beta.example.com') },
source: { kind: 'local', path: '/tmp' },
},
];
describe('mcporter serve bridge', () => {
it('selects only keep-alive servers and validates explicit filters', () => {
expect(selectServedServers(definitions).map((entry) => entry.name)).toEqual(['alpha', 'alpha-long']);
expect(selectServedServers(definitions, ['alpha']).map((entry) => entry.name)).toEqual(['alpha']);
expect(() => selectServedServers(definitions, ['beta'])).toThrow("Server 'beta' is not configured for keep-alive");
});
it('encodes and decodes namespaced tool names with longest-prefix matching', () => {
expect(encodeToolName('alpha', 'ping')).toBe('alpha__ping');
expect(decodeToolName('alpha-long__tool__with__separator', [{ name: 'alpha' }, { name: 'alpha-long' }])).toEqual({
server: 'alpha-long',
tool: 'tool__with__separator',
});
});
it('exposes daemon tools through a single MCP server', async () => {
const runtime = {
listTools: vi.fn().mockImplementation(async (server: string) => [
{
name: 'ping',
description: `${server} ping`,
inputSchema: {
type: 'object',
properties: { value: { type: 'number' } },
required: ['value'],
},
},
]),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'pong' }],
}),
};
const bridge = createBridgeServer({
runtime,
definitions,
servers: ['alpha'],
});
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: 'alpha__ping',
description: '[alpha] alpha ping',
inputSchema: expect.objectContaining({ required: ['value'] }),
}),
]);
expect(runtime.listTools).toHaveBeenCalledWith('alpha', {
includeSchema: true,
autoAuthorize: true,
});
await expect(client.callTool({ name: 'alpha__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('serves the bridge over Streamable HTTP', 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.');
}
expect(address.address).toBe('127.0.0.1');
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`));
try {
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toEqual(['alpha__ping']);
await expect(client.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
content: [{ type: 'text', text: 'pong-http' }],
});
} finally {
await client.close().catch(() => {});
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
it('returns 404 for paths outside the MCP endpoint', 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 response = await fetch(`http://127.0.0.1:${address.port}/mcp-extra`);
expect(response.status).toBe(404);
} finally {
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
});