From 7fdfc378b875b97b01abc2d3b2ef6047c0e1da29 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 09:43:15 +0300 Subject: [PATCH] LOU-Z3: expose an agent (and its tools) as an MCP server Adds serveMcp() to the ./mcp subpath: serves a createAgent() agent as one MCP tool ({ message }), optionally exposing defineTool() tools directly, over stdio or stateless Streamable HTTP (127.0.0.1 by default, optional bearer auth reusing checkWebhookAuth, warning on unauthenticated non-loopback binds). MCP request cancellation aborts the agent run; agent failures and approval pauses return isError. needsApproval tools are not exposed unless allowApprovalTools is set. Adds `loushy mcp [--http --port --host]` plus docs and a README bullet. Co-Authored-By: Claude Sonnet 5.5 --- .fallowrc.json | 2 +- README.md | 1 + bin/loushy.js | 9 + docs/configuration.md | 61 +++++ src/cli/mcp.test.ts | 62 +++++ src/cli/mcp.ts | 70 ++++++ src/tools/mcp/index.ts | 1 + src/tools/mcp/server/buildServer.ts | 120 ++++++++++ src/tools/mcp/server/httpTransport.ts | 156 ++++++++++++ src/tools/mcp/server/serveMcp.test.ts | 326 ++++++++++++++++++++++++++ src/tools/mcp/server/serveMcp.ts | 139 +++++++++++ tsup.config.ts | 1 + 12 files changed, 947 insertions(+), 1 deletion(-) create mode 100644 src/cli/mcp.test.ts create mode 100644 src/cli/mcp.ts create mode 100644 src/tools/mcp/server/buildServer.ts create mode 100644 src/tools/mcp/server/httpTransport.ts create mode 100644 src/tools/mcp/server/serveMcp.test.ts create mode 100644 src/tools/mcp/server/serveMcp.ts diff --git a/.fallowrc.json b/.fallowrc.json index 98bb3e770..3c5551b89 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/fallow/schema.json", "entry": [ - "src/cli/{dev,build,studio}.ts", + "src/cli/{dev,build,studio,mcp}.ts", "src/data/index.ts", "**/*.test-d.ts", "**/*.{test,spec}.{ts,tsx}", diff --git a/README.md b/README.md index b53c00fce..a4a59d646 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ dashboard — any provider, any deploy target, no lock-in. - **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor - **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used - **MCP client** — `loadMcpTools()` turns any Model Context Protocol server's tools into `ToolDescriptor`s +- **MCP server** — `serveMcp({ agent, name })` (or `loushy mcp agent.yaml`) exposes an agent as an MCP tool for Claude Code, Cursor and other agents - **Sandboxed tools** — opt a tool into `requiresSandbox` to route it through a Docker-backed `SandboxAdapter` instead of in-process - **Tracing & evals** — `withSpan()`/`TraceExporter` (with an OpenTelemetry bridge at `/otel`) for observability, and `defineEval()` for agent-behavior regression tests under `vitest` - **Any provider, any deploy target** — OpenAI, Anthropic, Ollama, OpenRouter, or a deterministic mock provider for tests; `loushy build` ships to a Node server, Docker, or Cloudflare Workers diff --git a/bin/loushy.js b/bin/loushy.js index 3960e951d..993d3a1cf 100644 --- a/bin/loushy.js +++ b/bin/loushy.js @@ -8,6 +8,7 @@ const USAGE = [ ' loushy dev [--port N] [--host H]', ' loushy build --target= --agent= [--out=]', ' loushy studio [--port N] [--host H] [--prod|--dev]', + ' loushy mcp [--http --port N --host H]', ].join('\n'); /** @@ -77,10 +78,18 @@ async function runStudio(rest) { } } +// Serves an agent spec over MCP (stdio by default). The server keeps the +// process alive; see src/cli/mcp.ts. Only the exit code is set on failure. +async function runMcp(rest) { + const { runMcp: start } = require(path.join(__dirname, '..', 'dist', 'cli', 'mcp.js')); + process.exitCode = await start(rest); +} + const COMMANDS = new Map([ ['dev', runDev], ['build', runBuildCommand], ['studio', runStudio], + ['mcp', runMcp], ]); async function main() { diff --git a/docs/configuration.md b/docs/configuration.md index 73b1b86d9..6a7f0e761 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,6 +58,67 @@ const agent = createAgent({ prompt: '...', provider, tools }); `loadMcpTools` is also available from the package root and from `@loushy/build-ai-agent/tools`. +### Serve an agent over MCP + +`serveMcp()` is the reverse of `loadMcpTools()`: it exposes an agent (and, +optionally, some of its tools) as an MCP server, so Claude Code, Cursor and +other MCP clients can call it. + +```ts +import { createAgent, defineTool } from '@loushy/build-ai-agent'; +import { serveMcp } from '@loushy/build-ai-agent/mcp'; +import { z } from 'zod'; + +const searchDocs = defineTool({ + name: 'search_docs', + description: 'Search the docs', + input: z.object({ query: z.string() }), + execute: ({ query }) => `results for ${query}`, +}); + +const supportAgent = createAgent({ prompt: 'You answer support questions.', provider, tools: [searchDocs] }); + +const server = await serveMcp({ + agent: supportAgent, // exposed as ONE tool taking { message: string } + name: 'support-bot', // server name; the tool name defaults to a sanitized version + description: 'Ask the support agent a question', + tools: [searchDocs], // optional: also expose these tools directly + transport: { type: 'http', port: 3920, host: '127.0.0.1', path: '/mcp' }, // default: 'stdio' +}); +await server.close(); +``` + +- **Stateless.** Every call to the agent tool is a fresh conversation. +- **Cancellation.** Cancelling the MCP request aborts the agent run + (`agent.send(message, { signal })`). +- **Errors.** An agent failure comes back as an MCP result with `isError: true`. +- **Approvals.** Approval-gated tools cannot be approved over MCP. A run that + pauses for approval returns `isError: true` with a message saying so. Tools + flagged `needsApproval` are not exposed directly unless you pass + `allowApprovalTools: true`; if you do, clients run them with **no human gate**. +- **stdio.** Nothing but the MCP protocol is written to stdout; warnings go to stderr. +- **HTTP.** Binds `127.0.0.1` by default. Add `auth: { type: 'bearer', token }` + to require an `Authorization: Bearer` header; binding a non-loopback host + without `auth` logs a warning. + +From the command line, `loushy mcp` serves an agent spec file (stdio by default): + +```sh +npx loushy mcp agent.yaml +npx loushy mcp agent.yaml --http --port 3920 --host 127.0.0.1 +``` + +To use it from an MCP client, add it to the client's MCP config (for example +`.mcp.json` for Claude Code): + +```json +{ + "mcpServers": { + "support-bot": { "command": "npx", "args": ["loushy", "mcp", "agent.yaml"] } + } +} +``` + ## Provider credentials Real providers are resolved by `resolveProvider('/')` diff --git a/src/cli/mcp.test.ts b/src/cli/mcp.test.ts new file mode 100644 index 000000000..ebf1a5188 --- /dev/null +++ b/src/cli/mcp.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parseMcpArgs, runMcp, startMcpServer } from './mcp'; +import { LLMProviderRegistry } from '../providers/llm'; +import { createMockProvider } from '../providers/mock'; + +beforeAll(() => { + LLMProviderRegistry.register('mock', () => createMockProvider({ responses: ['hello'] })); +}); + +function writeSpec(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'loushy-mcp-test-')); + const file = path.join(dir, 'agent.json'); + fs.writeFileSync( + file, + JSON.stringify({ name: 'cli agent', prompt: 'p', provider: { type: 'mock', model: 'm' } }) + ); + return file; +} + +describe('parseMcpArgs', () => { + it('defaults to stdio-less defaults with the spec path', () => { + expect(parseMcpArgs(['agent.yaml'])).toEqual({ configPath: 'agent.yaml', http: false, port: 3920, host: '127.0.0.1' }); + }); + + it('reads --http, --port and --host in both flag styles', () => { + expect(parseMcpArgs(['--http', '--port', '4000', '--host=0.0.0.0', 'a.json'])).toEqual({ + configPath: 'a.json', + http: true, + port: 4000, + host: '0.0.0.0', + }); + }); + + it('explains how to fix a missing path or bad port', () => { + expect(() => parseMcpArgs([])).toThrow(/spec file path is required/); + expect(() => parseMcpArgs(['a.json', '--port', 'abc'])).toThrow(/--port must be an integer/); + }); +}); + +describe('startMcpServer', () => { + it('serves a spec over HTTP', async () => { + const server = await startMcpServer({ configPath: writeSpec(), http: true, port: 0, host: '127.0.0.1' }); + try { + expect(server.agentToolName).toBe('cli_agent'); + expect(server.url).toMatch(/\/mcp$/); + } finally { + await server.close(); + } + }); +}); + +describe('runMcp', () => { + it('returns 1 with a message when the spec is missing', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(await runMcp(['/does/not/exist.json', '--http', '--port', '0'])).toBe(1); + expect(error).toHaveBeenCalled(); + error.mockRestore(); + }); +}); diff --git a/src/cli/mcp.ts b/src/cli/mcp.ts new file mode 100644 index 000000000..1467af3a1 --- /dev/null +++ b/src/cli/mcp.ts @@ -0,0 +1,70 @@ +/** + * `loushy mcp [--http --port N --host H]` - serve an agent + * spec over MCP (LOU-Z3). stdio by default; stdout carries only the MCP + * protocol there, so every message from this command goes to stderr. + */ +import * as path from 'node:path'; +import { loadSpec } from '../spec/loadSpec'; +import { specToAgent } from '../spec/specToAgent'; +import { serveMcp, type ServeMcpHandle } from '../tools/mcp/server/serveMcp'; + +const USAGE = 'Usage: loushy mcp [--http --port N --host H]'; + +/** Parsed `loushy mcp` arguments. */ +export interface McpCliArgs { + configPath: string; + http: boolean; + port: number; + host: string; +} + +function readValue(rest: string[], name: string): string | undefined { + const index = rest.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`)); + if (index === -1) return undefined; + const inline = rest[index].split('=')[1]; + return inline ?? rest[index + 1]; +} + +/** Parses `loushy mcp` arguments; throws an Error that says how to fix a bad invocation. */ +export function parseMcpArgs(rest: string[]): McpCliArgs { + const valueIndexes = ['--port', '--host'].map((flag) => rest.indexOf(flag)).filter((i) => i >= 0); + const configPath = rest.find( + (arg, index) => !arg.startsWith('--') && !valueIndexes.some((i) => i + 1 === index) + ); + if (!configPath) throw new Error(`loushy mcp: an agent spec file path is required. ${USAGE}`); + + const port = Number(readValue(rest, 'port') ?? 3920); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`loushy mcp: --port must be an integer between 0 and 65535. ${USAGE}`); + } + return { + configPath, + http: rest.includes('--http'), + port, + host: readValue(rest, 'host') ?? '127.0.0.1', + }; +} + +/** Loads the spec and starts serving it. Resolves with the running server. */ +export async function startMcpServer(args: McpCliArgs): Promise { + const spec = loadSpec(path.resolve(args.configPath)); + const agent = specToAgent(spec); + return serveMcp({ + agent, + name: spec.name, + transport: args.http ? { type: 'http', port: args.port, host: args.host } : 'stdio', + warn: (message) => console.error(message), + }); +} + +/** CLI entry point. Resolves with the process exit code once the server is up. */ +export async function runMcp(rest: string[]): Promise { + try { + const server = await startMcpServer(parseMcpArgs(rest)); + if (server.url) console.error(`loushy mcp: serving on ${server.url}`); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/src/tools/mcp/index.ts b/src/tools/mcp/index.ts index 1e7cb1a7a..efbf05807 100644 --- a/src/tools/mcp/index.ts +++ b/src/tools/mcp/index.ts @@ -9,3 +9,4 @@ export * from './McpToolLoader'; export * from './result'; export * from './schema'; +export * from './server/serveMcp'; diff --git a/src/tools/mcp/server/buildServer.ts b/src/tools/mcp/server/buildServer.ts new file mode 100644 index 000000000..8f37b3db8 --- /dev/null +++ b/src/tools/mcp/server/buildServer.ts @@ -0,0 +1,120 @@ +/** + * Builds the `McpServer` that fronts an agent (and optionally some of its + * tools). Transport-agnostic: `serveMcp` connects the result to stdio or HTTP. + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { SimpleAgent } from '../../../createAgent'; +import type { ExecutionResult } from '../../../execution/AgentExecutor'; +import type { DefinedTool } from '../../defineTool'; + +/** What {@link buildServer} needs, already validated by `serveMcp`. */ +export interface ServerSpec { + agent: SimpleAgent; + name: string; + version: string; + description?: string; + /** Name of the tool that runs the agent. */ + agentToolName: string; + tools: readonly DefinedTool[]; + allowApprovalTools: boolean; +} + +const TOOL_NAME_MAX = 64; + +/** Turns a server name into a valid MCP tool name (`[A-Za-z0-9_-]`, at most 64 characters). */ +export function sanitizeToolName(name: string): string { + const cleaned = name.trim().replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, ''); + return cleaned.slice(0, TOOL_NAME_MAX) || 'agent'; +} + +/** True when a tool pauses for a human before running (a flag or a predicate). */ +export function needsApprovalGate(tool: DefinedTool): boolean { + return tool.needsApproval !== undefined && tool.needsApproval !== false; +} + +function textResult(text: string, isError = false): CallToolResult { + return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Maps a finished agent run to an MCP tool result. */ +function agentResultToMcp(result: ExecutionResult): CallToolResult { + if (result.finishReason === 'awaiting-approval') { + return textResult( + 'The agent paused because one of its tools needs human approval. ' + + 'Approval-gated tools cannot be approved over MCP, so this call cannot finish. ' + + 'Remove needsApproval from the tool, or run the agent where approvals can be given.', + true + ); + } + if (result.finishReason === 'aborted') return textResult('The agent run was cancelled.', true); + if (result.finishReason === 'error') { + return textResult(`The agent failed: ${result.text || 'the model reported an error'}`, true); + } + return textResult(result.text); +} + +function registerAgentTool(server: McpServer, spec: ServerSpec): void { + server.registerTool( + spec.agentToolName, + { + description: spec.description ?? `Send a message to the ${spec.name} agent and get its reply.`, + inputSchema: { message: z.string().describe('The message to send to the agent.') }, + }, + async ({ message }, extra) => { + try { + // Each call is a fresh conversation; the MCP request signal cancels the run. + return agentResultToMcp(await spec.agent.send(message, { signal: extra.signal })); + } catch (error) { + return textResult(`The agent failed: ${errorMessage(error)}`, true); + } + } + ); +} + +function stringifyOutput(output: unknown): string { + if (typeof output === 'string') return output; + return JSON.stringify(output ?? null) ?? 'null'; +} + +type ToolExecute = ( + args: unknown, + options: { toolCallId: string; messages: never[]; abortSignal: AbortSignal } +) => unknown; + +function registerDirectTool(server: McpServer, tool: DefinedTool): void { + server.registerTool( + tool.name, + { description: tool.description, inputSchema: tool.input }, + async (args: unknown, extra) => { + try { + const execute = tool.tool.execute as ToolExecute | undefined; + if (!execute) return textResult(`Tool '${tool.name}' has no execute function.`, true); + const output = await execute(args, { + toolCallId: String(extra.requestId), + messages: [], + abortSignal: extra.signal, + }); + return textResult(stringifyOutput(output)); + } catch (error) { + return textResult(`Tool '${tool.name}' failed: ${errorMessage(error)}`, true); + } + } + ); +} + +/** Builds an `McpServer` exposing the agent tool plus the directly exposed tools. */ +export function buildServer(spec: ServerSpec): McpServer { + const server = new McpServer({ name: spec.name, version: spec.version }); + registerAgentTool(server, spec); + for (const tool of spec.tools) { + if (needsApprovalGate(tool) && !spec.allowApprovalTools) continue; + registerDirectTool(server, tool); + } + return server; +} diff --git a/src/tools/mcp/server/httpTransport.ts b/src/tools/mcp/server/httpTransport.ts new file mode 100644 index 000000000..7d60c1fc0 --- /dev/null +++ b/src/tools/mcp/server/httpTransport.ts @@ -0,0 +1,156 @@ +/** + * Streamable HTTP front for `serveMcp`. Stateless: every request gets its own + * `McpServer` + transport pair, which is what the MCP SDK recommends when no + * session state is kept. + */ +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { checkWebhookAuth } from '../../../triggers/webhookAuth'; + +/** Options of the `{ type: 'http' }` transport. */ +export interface McpHttpTransportOptions { + type: 'http'; + /** Port to listen on. Use `0` for an ephemeral port. Defaults to `3920`. */ + port?: number; + /** Interface to bind. Defaults to `'127.0.0.1'` (loopback only). */ + host?: string; + /** URL path the MCP endpoint is served on. Defaults to `'/mcp'`. */ + path?: string; + /** + * Require `Authorization: Bearer `. Strongly recommended whenever + * `host` is not a loopback address. + * + * @example + * ```ts + * const transport = { type: 'http', host: '0.0.0.0', auth: { type: 'bearer', token: 'secret' } } as const; + * ``` + */ + auth?: { type: 'bearer'; token: string }; +} + +/** A running HTTP listener. */ +interface HttpListener { + port: number; + host: string; + path: string; + close(): Promise; +} + +const MAX_BODY_BYTES = 4 * 1024 * 1024; + +function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '::1' || host === '[::1]' || /^127\./.test(host); +} + +function jsonRpcError( + res: http.ServerResponse, + status: number, + message: string, + headers: http.OutgoingHttpHeaders = {} +): void { + res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message }, id: null })); +} + +function readJsonBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let bytes = 0; + req.on('data', (chunk: Buffer) => { + bytes += chunk.length; + if (bytes <= MAX_BODY_BYTES) chunks.push(chunk); + }); + req.on('end', () => { + if (bytes > MAX_BODY_BYTES) return reject(new Error('Request body is too large.')); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new Error('Request body is not valid JSON.')); + } + }); + req.on('error', reject); + }); +} + +async function serveRequest( + createServer: () => McpServer, + req: http.IncomingMessage, + res: http.ServerResponse +): Promise { + const body = await readJsonBody(req); + const mcp = createServer(); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + res.on('close', () => { + void transport.close(); + void mcp.close(); + }); + await mcp.connect(transport); + await transport.handleRequest(req, res, body); +} + +async function handle( + createServer: () => McpServer, + path: string, + auth: McpHttpTransportOptions['auth'], + req: http.IncomingMessage, + res: http.ServerResponse +): Promise { + if (new URL(req.url ?? '/', 'http://localhost').pathname !== path) { + return jsonRpcError(res, 404, `Not found. The MCP endpoint is ${path}.`); + } + if (auth && (await checkWebhookAuth(auth, req, Buffer.alloc(0)))) { + return jsonRpcError(res, 401, 'Unauthorized: send "Authorization: Bearer ".', { + 'WWW-Authenticate': 'Bearer', + }); + } + if (req.method !== 'POST') { + return jsonRpcError(res, 405, 'Method not allowed: this server is stateless, use POST.', { Allow: 'POST' }); + } + try { + await serveRequest(createServer, req, res); + } catch (error) { + if (!res.headersSent) jsonRpcError(res, 400, error instanceof Error ? error.message : String(error)); + } +} + +function assertAuth(auth: McpHttpTransportOptions['auth']): void { + if (auth && !auth.token) { + throw new Error( + "serveMcp: transport.auth.token must be a non-empty string (e.g. auth: { type: 'bearer', token: process.env.MCP_TOKEN })." + ); + } +} + +/** Starts the HTTP listener. Resolves once it is accepting connections. */ +export function listenHttp( + options: McpHttpTransportOptions, + createServer: () => McpServer, + warn: (message: string) => void +): Promise { + const host = options.host ?? '127.0.0.1'; + const path = options.path ?? '/mcp'; + assertAuth(options.auth); + if (!options.auth && !isLoopbackHost(host)) { + warn( + `serveMcp: listening on ${host} without authentication - anyone who can reach this port can run your agent. ` + + "Set transport.auth ({ type: 'bearer', token }) or bind 127.0.0.1." + ); + } + const server = http.createServer((req, res) => { + void handle(createServer, path, options.auth, req, res); + }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(options.port ?? 3920, host, () => { + const { port } = server.address() as AddressInfo; + const close = () => + new Promise((done) => { + server.close(() => done()); + server.closeAllConnections(); + }); + resolve({ port, host, path, close }); + }); + }); +} diff --git a/src/tools/mcp/server/serveMcp.test.ts b/src/tools/mcp/server/serveMcp.test.ts new file mode 100644 index 000000000..8cc03bfb5 --- /dev/null +++ b/src/tools/mcp/server/serveMcp.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { z } from 'zod'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createAgent, type SimpleAgent } from '../../../createAgent'; +import { defineTool } from '../../defineTool'; +import { mockModel } from '../../../testing'; +import { buildServer, sanitizeToolName, type ServerSpec } from './buildServer'; +import { serveMcp, type ServeMcpHandle } from './serveMcp'; + +const searchDocs = defineTool({ + name: 'search_docs', + description: 'Search the docs', + input: z.object({ query: z.string() }), + execute: ({ query }) => `results for ${query}`, +}); + +const deleteAll = defineTool({ + name: 'delete_all', + description: 'Deletes everything', + input: z.object({}), + needsApproval: true, + execute: () => 'deleted', +}); + +const brokenTool = defineTool({ + name: 'broken', + description: 'Always fails', + input: z.object({}), + execute: () => { + throw new Error('boom'); + }, +}); + +function spec(agent: SimpleAgent, overrides: Partial = {}): ServerSpec { + return { + agent, + name: 'support-bot', + version: '1.0.0', + agentToolName: 'support-bot', + tools: [], + allowApprovalTools: false, + ...overrides, + }; +} + +const closers: Array<() => Promise> = []; +afterEach(async () => { + await Promise.all(closers.splice(0).map((close) => close())); +}); + +async function connect(serverSpec: ServerSpec): Promise { + const server = buildServer(serverSpec); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + closers.push(() => client.close(), () => server.close()); + return client; +} + +function textOf(result: Awaited>): string { + const content = result.content as Array<{ type: string; text: string }>; + return content.map((c) => c.text).join(''); +} + +describe('serveMcp agent tool', () => { + it('lists the agent as one tool with a { message } input schema', async () => { + const agent = createAgent({ prompt: 'p', provider: mockModel(['hi']) }); + const client = await connect(spec(agent, { description: 'Ask the support agent a question' })); + const { tools } = await client.listTools(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('support-bot'); + expect(tools[0].description).toBe('Ask the support agent a question'); + expect(tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { message: { type: 'string' } }, + required: ['message'], + }); + }); + + it("calls agent.send and returns the agent's text", async () => { + const model = mockModel(['Reset it from Settings.']); + const agent = createAgent({ prompt: 'p', provider: model }); + const client = await connect(spec(agent)); + const result = await client.callTool({ name: 'support-bot', arguments: { message: 'How do I reset?' } }); + expect(result.isError).toBeUndefined(); + expect(textOf(result)).toBe('Reset it from Settings.'); + expect(JSON.stringify(model.lastCall)).toContain('How do I reset?'); + }); + + it('treats each call as a fresh conversation', async () => { + const model = mockModel(['one', 'two']); + const client = await connect(spec(createAgent({ prompt: 'p', provider: model }))); + await client.callTool({ name: 'support-bot', arguments: { message: 'first' } }); + await client.callTool({ name: 'support-bot', arguments: { message: 'second' } }); + expect(JSON.stringify(model.lastCall)).not.toContain('first'); + }); + + it('rejects arguments that do not match the schema', async () => { + const client = await connect(spec(createAgent({ prompt: 'p', provider: mockModel(['x']) }))); + const result = await client.callTool({ name: 'support-bot', arguments: { message: 42 } }); + expect(result.isError).toBe(true); + }); + + it('returns isError when the agent throws', async () => { + const agent = { send: vi.fn().mockRejectedValue(new Error('model down')) }; + const client = await connect(spec(agent)); + const result = await client.callTool({ name: 'support-bot', arguments: { message: 'hi' } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('model down'); + }); + + it('returns isError when the model reports an error', async () => { + const model = mockModel([{ text: 'rate limited', finishReason: 'error' }]); + const client = await connect(spec(createAgent({ prompt: 'p', provider: model }))); + const result = await client.callTool({ name: 'support-bot', arguments: { message: 'hi' } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('The agent failed'); + }); + + it('returns a clear isError when the run pauses for approval', async () => { + const paused = { text: '', finishReason: 'awaiting-approval', approvalId: 'a1', messages: [], toolCalls: [], steps: 1, usage: {} }; + const agent = { send: vi.fn().mockResolvedValue(paused) }; + const client = await connect(spec(agent as unknown as SimpleAgent)); + const result = await client.callTool({ name: 'support-bot', arguments: { message: 'wipe' } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toMatch(/needs human approval.*cannot be approved over MCP/s); + }); + + it('returns isError when an approval-gated tool is hit by an agent without an approval store', async () => { + const model = mockModel([{ toolCalls: [{ name: 'delete_all' }] }, 'done']); + const agent = createAgent({ prompt: 'p', provider: model, tools: [deleteAll] }); + const client = await connect(spec(agent)); + const result = await client.callTool({ name: 'support-bot', arguments: { message: 'wipe' } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('requires approval'); + }); + + it('reports an aborted run as an error', async () => { + const agent = { + send: vi.fn().mockResolvedValue({ text: '', finishReason: 'aborted', messages: [], toolCalls: [], steps: 0, usage: {} }), + }; + const client = await connect(spec(agent as unknown as SimpleAgent)); + const result = await client.callTool({ name: 'support-bot', arguments: { message: 'hi' } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('cancelled'); + }); + + it('aborts the agent run when the MCP request is cancelled', async () => { + let seen: AbortSignal | undefined; + let started!: () => void; + const startedPromise = new Promise((resolve) => (started = resolve)); + const agent = { + send: vi.fn((_message: string, options?: { signal?: AbortSignal }) => { + seen = options?.signal; + started(); + return new Promise((resolve) => { + options?.signal?.addEventListener('abort', () => + resolve({ text: '', finishReason: 'aborted', messages: [], toolCalls: [], steps: 0, usage: {} }) + ); + }); + }), + }; + const client = await connect(spec(agent as unknown as SimpleAgent)); + const controller = new AbortController(); + const call = client.callTool( + { name: 'support-bot', arguments: { message: 'slow' } }, + undefined, + { signal: controller.signal } + ); + await startedPromise; + controller.abort(); + await expect(call).rejects.toBeDefined(); + await vi.waitFor(() => expect(seen?.aborted).toBe(true)); + }); +}); + +describe('serveMcp direct tools', () => { + const agent = createAgent({ prompt: 'p', provider: mockModel(['x']) }); + + it('exposes tools with a JSON Schema derived from the zod input', async () => { + const client = await connect(spec(agent, { tools: [searchDocs] })); + const { tools } = await client.listTools(); + const found = tools.find((t) => t.name === 'search_docs'); + expect(found?.description).toBe('Search the docs'); + expect(found?.inputSchema).toMatchObject({ properties: { query: { type: 'string' } }, required: ['query'] }); + }); + + it('runs a tool and validates its arguments', async () => { + const client = await connect(spec(agent, { tools: [searchDocs] })); + const ok = await client.callTool({ name: 'search_docs', arguments: { query: 'mcp' } }); + expect(textOf(ok)).toBe('results for mcp'); + const bad = await client.callTool({ name: 'search_docs', arguments: { query: 1 } }); + expect(bad.isError).toBe(true); + }); + + it('serializes non-string results as JSON', async () => { + const objectTool = defineTool({ name: 'obj', description: 'd', input: z.object({}), execute: () => ({ a: 1 }) }); + const client = await connect(spec(agent, { tools: [objectTool] })); + expect(textOf(await client.callTool({ name: 'obj', arguments: {} }))).toBe('{"a":1}'); + }); + + it('maps a throwing tool to isError', async () => { + const client = await connect(spec(agent, { tools: [brokenTool] })); + const result = await client.callTool({ name: 'broken', arguments: {} }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('boom'); + }); + + it('hides needsApproval tools unless allowApprovalTools is set', async () => { + const hidden = await connect(spec(agent, { tools: [searchDocs, deleteAll] })); + expect((await hidden.listTools()).tools.map((t) => t.name).sort()).toEqual(['search_docs', 'support-bot']); + + const shown = await connect(spec(agent, { tools: [searchDocs, deleteAll], allowApprovalTools: true })); + expect((await shown.listTools()).tools.map((t) => t.name)).toContain('delete_all'); + expect(textOf(await shown.callTool({ name: 'delete_all', arguments: {} }))).toBe('deleted'); + }); +}); + +describe('serveMcp options', () => { + const agent = createAgent({ prompt: 'p', provider: mockModel(['x']) }); + + it('sanitizes the default tool name', () => { + expect(sanitizeToolName('Support Bot!')).toBe('Support_Bot'); + expect(sanitizeToolName('!!!')).toBe('agent'); + expect(sanitizeToolName('x'.repeat(100))).toHaveLength(64); + }); + + it('validates options with fix-it messages', async () => { + await expect(serveMcp({ agent: {} as SimpleAgent, name: 'a' })).rejects.toThrow(/createAgent/); + await expect(serveMcp({ agent, name: '' })).rejects.toThrow(/`name` is required/); + await expect(serveMcp({ agent, name: 'search_docs', tools: [searchDocs] })).rejects.toThrow(/two tools are named/); + const notObject = defineTool({ name: 'str', description: 'd', input: z.string(), execute: () => 'x' }); + await expect(serveMcp({ agent, name: 'a', tools: [notObject] })).rejects.toThrow(/z\.object/); + }); + + it('warns when allowApprovalTools exposes a gated tool', async () => { + const warn = vi.fn(); + const server = await serveMcp({ + agent, + name: 'a', + tools: [deleteAll], + allowApprovalTools: true, + transport: { type: 'http', port: 0 }, + warn, + }); + closers.push(server.close); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('WITHOUT a human gate')); + }); +}); + +describe('serveMcp over HTTP', () => { + const agent = createAgent({ prompt: 'p', provider: mockModel(['pong'], { onExhausted: 'repeat-last' }) }); + + async function start(options: Partial[0]> & { auth?: boolean } = {}): Promise { + const server = await serveMcp({ + agent, + name: 'support-bot', + transport: { type: 'http', port: 0, ...(options.auth ? { auth: { type: 'bearer' as const, token: 's3cret' } } : {}) }, + warn: () => {}, + }); + closers.push(server.close); + return server; + } + + async function post(url: string, headers: Record = {}): Promise { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + } + + it('completes a client round-trip on an ephemeral port bound to 127.0.0.1', async () => { + const server = await start(); + expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/); + const client = new Client({ name: 'c', version: '1' }); + await client.connect(new StreamableHTTPClientTransport(new URL(server.url!))); + closers.push(() => client.close()); + const result = await client.callTool({ name: 'support-bot', arguments: { message: 'ping' } }); + expect(textOf(result)).toBe('pong'); + }); + + it('rejects missing or wrong bearer tokens with 401 and accepts the right one', async () => { + const server = await start({ auth: true }); + expect((await post(server.url!)).status).toBe(401); + expect((await post(server.url!, { Authorization: 'Bearer nope' })).status).toBe(401); + expect((await post(server.url!, { Authorization: 'Bearer s3cret' })).status).toBe(200); + }); + + it('answers 404 on other paths and 405 on non-POST', async () => { + const server = await start(); + expect((await post(server.url!.replace('/mcp', '/other'))).status).toBe(404); + expect((await fetch(server.url!)).status).toBe(405); + }); + + it('answers 400 on invalid JSON', async () => { + const server = await start(); + const res = await fetch(server.url!, { method: 'POST', body: 'not json' }); + expect(res.status).toBe(400); + }); + + it('warns once when bound to a non-loopback host without auth', async () => { + const warn = vi.fn(); + const server = await serveMcp({ agent, name: 'a', transport: { type: 'http', port: 0, host: '0.0.0.0' }, warn }); + closers.push(server.close); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('without authentication')); + }); + + it('does not warn on non-loopback hosts when auth is set, and rejects an empty token', async () => { + const warn = vi.fn(); + const server = await serveMcp({ + agent, + name: 'a', + transport: { type: 'http', port: 0, host: '0.0.0.0', auth: { type: 'bearer', token: 't' } }, + warn, + }); + closers.push(server.close); + expect(warn).not.toHaveBeenCalled(); + await expect( + serveMcp({ agent, name: 'a', transport: { type: 'http', port: 0, auth: { type: 'bearer', token: '' } } }) + ).rejects.toThrow(/token must be a non-empty string/); + }); +}); diff --git a/src/tools/mcp/server/serveMcp.ts b/src/tools/mcp/server/serveMcp.ts new file mode 100644 index 000000000..183d4270d --- /dev/null +++ b/src/tools/mcp/server/serveMcp.ts @@ -0,0 +1,139 @@ +/** + * `serveMcp()` - expose a Loushy agent (and optionally some of its tools) as + * a Model Context Protocol server, so Claude Code, Cursor and other MCP + * clients can call it (LOU-Z3). + */ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { SimpleAgent } from '../../../createAgent'; +import type { DefinedTool } from '../../defineTool'; +import { buildServer, needsApprovalGate, sanitizeToolName } from './buildServer'; +import { listenHttp, type McpHttpTransportOptions } from './httpTransport'; + +export type { McpHttpTransportOptions } from './httpTransport'; + +/** Options for {@link serveMcp}. */ +export interface ServeMcpOptions { + /** The agent from `createAgent()`. It is exposed as ONE tool taking `{ message: string }`. */ + agent: SimpleAgent; + /** MCP server name, shown by clients. Also the base of the default tool name. */ + name: string; + /** Description of the agent tool; tells the calling model when to use it. */ + description?: string; + /** Server version reported to clients. Defaults to `'1.0.0'`. */ + version?: string; + /** Name of the agent tool. Defaults to `name` with characters outside `[A-Za-z0-9_-]` replaced by `_`. */ + toolName?: string; + /** Tools (from `defineTool()`) to also expose directly, next to the agent tool. */ + tools?: readonly DefinedTool[]; + /** + * Expose tools flagged `needsApproval` too. Off by default. MCP has no + * approval step here, so enabling this lets clients run those tools with + * NO human gate. + */ + allowApprovalTools?: boolean; + /** `'stdio'` (default) or `{ type: 'http', port, host, path, auth }`. */ + transport?: 'stdio' | McpHttpTransportOptions; + /** Receives one-time warnings. Defaults to stderr (stdout is reserved for the stdio protocol). */ + warn?: (message: string) => void; +} + +/** A running MCP server returned by {@link serveMcp}. */ +export interface ServeMcpHandle { + /** Name of the tool that runs the agent. */ + readonly agentToolName: string; + /** Endpoint URL (HTTP transport only). */ + readonly url?: string; + /** Bound port (HTTP transport only). */ + readonly port?: number; + /** Stops serving and releases the transport. */ + close(): Promise; +} + +function isObjectSchema(schema: unknown): boolean { + return (schema as { _def?: { typeName?: string } } | undefined)?._def?.typeName === 'ZodObject'; +} + +function assertTools(tools: readonly DefinedTool[], agentToolName: string): void { + const seen = new Set([agentToolName]); + for (const tool of tools) { + if (!isObjectSchema(tool.input)) { + throw new Error(`serveMcp: tool '${tool.name}' needs a z.object(...) input; MCP tool inputs must be objects.`); + } + if (seen.has(tool.name)) { + throw new Error( + `serveMcp: two tools are named '${tool.name}'. Rename the tool, or set \`toolName\` to rename the agent tool.` + ); + } + seen.add(tool.name); + } +} + +function assertOptions(options: ServeMcpOptions, agentToolName: string): void { + if (!options.agent || typeof options.agent.send !== 'function') { + throw new Error('serveMcp: `agent` must be the result of createAgent() (an object with a send() method).'); + } + if (!options.name) { + throw new Error("serveMcp: `name` is required (e.g. serveMcp({ agent, name: 'support-bot' }))."); + } + assertTools(options.tools ?? [], agentToolName); +} + +function stderrWarn(message: string): void { + console.error(message); +} + +interface Running { + url?: string; + port?: number; + close(): Promise; +} + +async function listen( + transport: ServeMcpOptions['transport'], + create: () => McpServer, + warn: (message: string) => void +): Promise { + if (transport !== undefined && transport !== 'stdio') { + const http = await listenHttp(transport, create, warn); + const host = http.host.includes(':') ? `[${http.host}]` : http.host; + return { port: http.port, url: `http://${host}:${http.port}${http.path}`, close: http.close }; + } + const server = create(); + await server.connect(new StdioServerTransport()); + return { close: () => server.close() }; +} + +/** + * Serves an agent over MCP. Each MCP call is a fresh, stateless conversation. + * Tools flagged `needsApproval` are not exposed unless `allowApprovalTools` + * is set, and an agent run that pauses for approval is returned as an error + * (approvals cannot be given over MCP). + * + * @example + * ```ts + * const server = await serveMcp({ agent, name: 'support-bot', description: 'Ask the support agent a question' }); + * await server.close(); + * ``` + */ +export async function serveMcp(options: ServeMcpOptions): Promise { + const agentToolName = sanitizeToolName(options.toolName ?? options.name); + assertOptions(options, agentToolName); + + const tools = options.tools ?? []; + const spec = { + agent: options.agent, + name: options.name, + version: options.version ?? '1.0.0', + description: options.description, + agentToolName, + tools, + allowApprovalTools: options.allowApprovalTools === true, + }; + const warn = options.warn ?? stderrWarn; + if (spec.allowApprovalTools && tools.some(needsApprovalGate)) { + warn('serveMcp: allowApprovalTools is on - tools flagged needsApproval will run WITHOUT a human gate.'); + } + const running = await listen(options.transport, () => buildServer(spec), warn); + return { agentToolName, url: running.url, port: running.port, close: running.close }; +} diff --git a/tsup.config.ts b/tsup.config.ts index f486b9193..eb4473be8 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ 'cli/dev': 'src/cli/dev.ts', 'cli/build': 'src/cli/build.ts', 'cli/studio': 'src/cli/studio.ts', + 'cli/mcp': 'src/cli/mcp.ts', 'execution/otel': 'src/execution/otel.ts', 'execution/hooks': 'src/execution/hooks.ts', 'triggers/index': 'src/triggers/index.ts',