diff --git a/README.md b/README.md index 29f2504..4b8ce8e 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e737771..82792d1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 ]` + +- 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 ` – serve MCP Streamable HTTP on `/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. + ## `mcporter generate-cli` - Produces a standalone CLI for a single MCP server (optionally bundling or diff --git a/docs/index.md b/docs/index.md index 9f9518d..12f9212 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 ` 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 diff --git a/src/cli.ts b/src/cli.ts index 6fe1320..caafc3c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -139,6 +139,21 @@ export async function runCli(argv: string[]): Promise { 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' || diff --git a/src/cli/help-output.ts b/src/cli/help-output.ts index 9a6b6d8..52b6211 100644 --- a/src/cli/help-output.ts +++ b/src/cli/help-output.ts @@ -112,6 +112,11 @@ function buildCommandSections(colorize: boolean): string[] { summary: 'Manage the keep-alive daemon (start | status | stop | restart)', usage: 'mcporter daemon ', }, + { + name: 'serve', + summary: 'Expose daemon-managed keep-alive servers as one MCP server', + usage: 'mcporter serve [--servers a,b,c] [--stdio | --http ]', + }, ], }, ]; diff --git a/src/cli/serve-command.ts b/src/cli/serve-command.ts new file mode 100644 index 0000000..4014dce --- /dev/null +++ b/src/cli/serve-command.ts @@ -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 { + 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>; + 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 ] + +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. + --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; +} diff --git a/src/serve.ts b/src/serve.ts new file mode 100644 index 0000000..0215140 --- /dev/null +++ b/src/serve.ts @@ -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; + 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 { + const server = createBridgeServer(options); + const transport = new StdioServerTransport(); + const closed = new Promise((resolve, reject) => { + transport.onclose = () => resolve(); + transport.onerror = (error) => reject(error); + }); + await server.connect(transport); + await closed; +} + +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') { + 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((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[] +): { 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'; +} diff --git a/tests/cli-serve-command.test.ts b/tests/cli-serve-command.test.ts new file mode 100644 index 0000000..030cc8a --- /dev/null +++ b/tests/cli-serve-command.test.ts @@ -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'."); + }); +}); diff --git a/tests/cli-serve-runtime.test.ts b/tests/cli-serve-runtime.test.ts new file mode 100644 index 0000000..22dae91 --- /dev/null +++ b/tests/cli-serve-runtime.test.ts @@ -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) => createRuntimeMock(...args), +})); + +vi.mock('../src/daemon/client.js', () => ({ + DaemonClient: DaemonClientMock, +})); + +vi.mock('../src/daemon/runtime-wrapper.js', () => ({ + createKeepAliveRuntime: (...args: Parameters) => + createKeepAliveRuntimeMock(...args), +})); + +vi.mock('../src/serve.js', async () => { + const actual = await vi.importActual('../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); + }); +}); diff --git a/tests/serve.test.ts b/tests/serve.test.ts new file mode 100644 index 0000000..c90513e --- /dev/null +++ b/tests/serve.test.ts @@ -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((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((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }); +});