feat: add mcporter serve bridge
This commit is contained in:
parent
f9f60d7cc4
commit
6879a69f49
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
16
src/cli.ts
16
src/cli.ts
@ -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' ||
|
||||
|
||||
@ -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
194
src/cli/serve-command.ts
Normal 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
212
src/serve.ts
Normal 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';
|
||||
}
|
||||
45
tests/cli-serve-command.test.ts
Normal file
45
tests/cli-serve-command.test.ts
Normal 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'.");
|
||||
});
|
||||
});
|
||||
137
tests/cli-serve-runtime.test.ts
Normal file
137
tests/cli-serve-runtime.test.ts
Normal 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
173
tests/serve.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user