From 3486a4ec79696b6a5fba3a69499f72528413ab5c Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Mon, 19 Oct 2026 22:11:06 +0530 Subject: [PATCH] feat(mcp): support authenticated streamable HTTP transport alongside stdio (#419) --- .env.example | 8 +++ README.md | 4 ++ examples/mcp-server/README.md | 33 +++++++++- examples/mcp-server/run.ts | 14 +++- src/mcp/server.ts | 119 +++++++++++++++++++++++++++++++++- tests/mcp.test.ts | 103 +++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index e9d0cb02..1fab2ffe 100644 --- a/.env.example +++ b/.env.example @@ -363,6 +363,14 @@ MCP_PAID_CALL_ALLOWED_HOSTS= # Leave unset to serve the openapi.json at the package root; set it only when the # document lives elsewhere, e.g. mounted separately in a container. MCP_OPENAPI_PATH= +# MCP transport mode: stdio (default) or http +MCP_TRANSPORT=stdio +# Required bearer / x-api-key token when MCP_TRANSPORT=http +MCP_API_KEY= +# HTTP port for MCP streamable HTTP transport (default: 3001) +MCP_HTTP_PORT=3001 +# HTTP host binding for MCP streamable HTTP transport (default: 0.0.0.0) +MCP_HTTP_HOST=0.0.0.0 # ─── Snapshot retention ──────────────────────────────────────────────────── diff --git a/README.md b/README.md index 86430e78..927f3b17 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,10 @@ and `stellar:pubnet`. **⚠️ Warning:** the mainnet key signs with real XLM. Read the configuration, spending warning, and worked example before running. +The server supports two transports selectable by configuration (`MCP_TRANSPORT`): +- `stdio` (default) for local subprocess agents. +- `http` for hosted/remote setups (Streamable HTTP, requires `MCP_API_KEY` authentication). + Full documentation: [examples/mcp-server/README.md](examples/mcp-server/README.md) ### WebSocket backpressure diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index bada952f..fe13f913 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -29,8 +29,13 @@ paid call from the mainnet account. | `MCP_AGENT_SECRET_KEY_TESTNET` | Yes | Stellar secret key used to sign payments on `stellar:testnet` | | `MCP_AGENT_SECRET_KEY_MAINNET` | Yes | Stellar secret key used to sign payments on `stellar:pubnet` (mainnet) — **real money** | | `MCP_MAX_PAYMENT_PRICE` | No | Maximum price the server will pay per request (default: `$1.00`) | +| `MCP_TRANSPORT` | No | Transport mode: `stdio` (default) or `http` | +| `MCP_API_KEY` | If HTTP | Shared secret for authenticating incoming HTTP requests. Refuses to start if missing when `MCP_TRANSPORT=http` | +| `MCP_HTTP_PORT` | No | Port for HTTP transport (default: `3001` or `PORT`) | +| `MCP_HTTP_HOST` | No | Host interface for HTTP transport (default: `0.0.0.0`) | Both keys are required: `run.ts` exits with an error if either is unset. +When running with `MCP_TRANSPORT=http`, `MCP_API_KEY` is also strictly required. The server talks to two Stellar networks: - `stellar:testnet` — safe, friendbot-funded play money @@ -64,13 +69,39 @@ export MCP_AGENT_SECRET_KEY_MAINNET= npm run mcp:server ``` -The server starts on stdio and logs: +The server starts on stdio (default) and logs: ``` [mcp-server] Starting Bazaar MCP server on stdio... [mcp-server] Agent configured for stellar:testnet and stellar:pubnet ``` +### Running on HTTP Transport (hosted / remote) + +To run the MCP server over HTTP/SSE instead of stdio, set `MCP_TRANSPORT=http` and provide `MCP_API_KEY`: + +```bash +export MCP_AGENT_SECRET_KEY_TESTNET= +export MCP_AGENT_SECRET_KEY_MAINNET= +export MCP_TRANSPORT=http +export MCP_API_KEY= +export MCP_HTTP_PORT=3001 # optional, defaults to 3001 +npm run mcp:server +``` + +The server starts on HTTP and logs: + +``` +[mcp-server] Starting Bazaar MCP server on HTTP transport (port 3001)... +[mcp-server] Authentication required via Authorization: Bearer or x-api-key header +[mcp-server] Agent configured for stellar:testnet and stellar:pubnet +``` + +**Transport Selection & Purpose:** +- **`stdio` (Default):** Best for local developer use and CLI agents spawned as child processes on the same machine. +- **`http`:** Streamable HTTP transport for remote or hosted Lens deployments where agents connect over the network. Every incoming HTTP request must include authentication (`Authorization: Bearer ` or `x-api-key: `), refusing unauthenticated calls with HTTP 401. Both transports expose the exact same tools (`bazaar_search` and `paid_call`). + + In another terminal, run the agent example, which connects an MCP client, lists the tools, and calls both of them: diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index 4d3bad5d..455e89ce 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -73,7 +73,19 @@ async function main() { paidCallAllowlist }); - console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + const transport = process.env.MCP_TRANSPORT === 'http' ? 'http' : 'stdio'; + const port = process.env.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT, 10) : (process.env.PORT ? parseInt(process.env.PORT, 10) : 3001); + + if (transport === 'http') { + if (!process.env.MCP_API_KEY) { + console.error('[mcp-server] Error: MCP_API_KEY must be set when running with MCP_TRANSPORT=http'); + process.exit(1); + } + console.error(`[mcp-server] Starting Bazaar MCP server on HTTP transport (port ${port})...`); + console.error('[mcp-server] Authentication required via Authorization: Bearer or x-api-key header'); + } else { + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + } console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); console.error(`[mcp-server] lens_get_price serves aggregated prices on ${activeNetwork}`); // Limits and file paths only: the signing key is never logged. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0ab584e7..126812a4 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,3 +1,4 @@ +import http from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { @@ -8,6 +9,8 @@ import { ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { MCP_ERROR_CODES, McpErrorCode, classifyPaidCallError } from './codes.js'; import { SpendBudget } from './budget.js'; import { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; @@ -72,6 +75,40 @@ function hostOf(url: string): string { } } +export interface McpRunOptions { + /** + * Transport to use: 'stdio' (default) connects via stdin/stdout; + * 'http' starts an HTTP streamable transport server. + * Can also be controlled via the MCP_TRANSPORT environment variable. + */ + transport?: 'stdio' | 'http'; + /** + * Port for the HTTP transport server. + * Defaults to MCP_HTTP_PORT || PORT || 3001. + */ + port?: number; + /** + * Host to bind the HTTP transport server. + * Defaults to MCP_HTTP_HOST || '0.0.0.0'. + */ + host?: string; + /** + * API key required for HTTP authentication. + * When HTTP transport is selected, requests must provide this key + * in `Authorization: Bearer ` or `x-api-key: `. + * Can also be provided via the MCP_API_KEY environment variable. + * If unset when HTTP transport is used, the server refuses to start. + */ + apiKey?: string; +} + +export interface McpRunningInstance { + transport: 'stdio' | 'http'; + httpServer?: http.Server; + port?: number; + close: () => Promise; +} + export interface McpServerConfig { fetchWithPayment: typeof fetch; /** @@ -581,8 +618,88 @@ export class BazaarMcpServer { }; } - async run() { + /** + * Connects the server to the given transport. Prefer this over `run()` when + * you manage the transport yourself. + */ + async connect(transport: Transport): Promise { + await this.server.connect(transport); + } + + /** + * Starts the MCP server on the configured transport. + * - Defaults to `stdio` unless configured otherwise. + * - Can be switched to `http` via `options.transport = 'http'` or `MCP_TRANSPORT=http`. + * - HTTP exposure strictly requires authentication (via `apiKey` or `MCP_API_KEY`) + * and refuses to start without one. + */ + async run(options: McpRunOptions = {}): Promise { + const transportType = options.transport ?? (process.env.MCP_TRANSPORT === 'http' ? 'http' : 'stdio'); + + if (transportType === 'http') { + const apiKey = options.apiKey ?? process.env.MCP_API_KEY; + if (!apiKey || apiKey.trim() === '') { + throw new Error('MCP HTTP transport requires authentication: set MCP_API_KEY or provide apiKey in options'); + } + + const port = options.port ?? (process.env.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT, 10) : (process.env.PORT ? parseInt(process.env.PORT, 10) : 3001)); + const host = options.host ?? process.env.MCP_HTTP_HOST ?? '0.0.0.0'; + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await this.server.connect(transport); + + const httpServer = http.createServer(async (req, res) => { + // Enforce authentication on all incoming requests + const authHeader = req.headers['authorization']; + const apiKeyHeader = req.headers['x-api-key']; + const token = authHeader?.startsWith('Bearer ') + ? authHeader.slice(7).trim() + : (typeof apiKeyHeader === 'string' ? apiKeyHeader.trim() : undefined); + + if (!token || token !== apiKey) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized: valid API key required for MCP HTTP transport' })); + return; + } + + try { + await transport.handleRequest(req, res); + } catch (err: any) { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err?.message || 'Internal error' })); + } + } + }); + + await new Promise((resolve, reject) => { + httpServer.listen(port, host, () => resolve()); + httpServer.on('error', reject); + }); + + const actualPort = (httpServer.address() as any)?.port ?? port; + + return { + transport: 'http', + httpServer, + port: actualPort, + close: async () => { + await transport.close(); + await new Promise((resolve) => httpServer.close(() => resolve())); + }, + }; + } + const transport = new StdioServerTransport(); await this.server.connect(transport); + + return { + transport: 'stdio', + close: async () => { + await transport.close(); + }, + }; } } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 5a029272..3084b999 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -486,6 +486,7 @@ describe('BazaarMcpServer bazaar_search upstream failures (#349)', () => { }); }); + import { classifyPaidCallError } from '../src/mcp/codes'; function captureHandler(server: BazaarMcpServer): any { @@ -854,3 +855,105 @@ describe('cumulative spend budget', () => { expect(() => parseUsdToNanodollars('$1.0000000000')).toThrow(/more than 9 decimal places/); }) }) + +describe('BazaarMcpServer — Transports (#419)', () => { + it('defaults to stdio transport when no options or env var is specified', async () => { + const oldEnv = process.env.MCP_TRANSPORT; + delete process.env.MCP_TRANSPORT; + try { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const running = await server.run(); + expect(running.transport).toBe('stdio'); + await running.close(); + } finally { + if (oldEnv) process.env.MCP_TRANSPORT = oldEnv; + } + }); + + it('refuses to start HTTP transport when apiKey / MCP_API_KEY is missing', async () => { + const oldKey = process.env.MCP_API_KEY; + delete process.env.MCP_API_KEY; + try { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + await expect(server.run({ transport: 'http' })).rejects.toThrow( + /MCP HTTP transport requires authentication/i + ); + } finally { + if (oldKey) process.env.MCP_API_KEY = oldKey; + } + }); + + it('starts HTTP transport with valid apiKey and enforces authentication on requests', async () => { + const apiKey = 'test-secret-key-12345'; + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + // Bind to port 0 (dynamic port) to avoid collisions + const running = await server.run({ transport: 'http', port: 0, host: '127.0.0.1', apiKey }); + expect(running.transport).toBe('http'); + expect(running.port).toBeGreaterThan(0); + + const baseUrl = `http://127.0.0.1:${running.port}`; + + try { + // 1. Request without auth should be rejected with 401 + const unauthRes = await fetch(baseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(unauthRes.status).toBe(401); + const unauthJson = await unauthRes.json(); + expect(unauthJson.error).toMatch(/Unauthorized/i); + + // 2. Request with invalid key should be rejected with 401 + const badAuthRes = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer wrong-key', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(badAuthRes.status).toBe(401); + + // 3. Request with valid x-api-key header should be accepted (not 401) + const validApiKeyRes = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(validApiKeyRes.status).not.toBe(401); + + // 4. Request with valid Bearer token should be accepted (not 401) + const validBearerRes = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(validBearerRes.status).not.toBe(401); + } finally { + await running.close(); + } + }); + + it('exposes identical tools across both stdio and HTTP setups', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + let listToolsHandler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === ListToolsRequestSchema) { + listToolsHandler = fn; + } + }; + (server as any).setupHandlers(); + + expect(listToolsHandler).toBeTruthy(); + const res = await listToolsHandler({ method: 'tools/list', params: {} }); + const toolNames = res.tools.map((t: any) => t.name).sort(); + expect(toolNames).toEqual(['bazaar_search', 'paid_call']); + }); +});