diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 5294c27..0caccc7 100755 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -1,202 +1,18 @@ -/* AgentBridge MCP server. +/* AgentBridge MCP server entry point. * - * Speaks MCP over stdio. Exposes tools, resources, and prompts to AI agents - * for discovering, scanning, and safely invoking AgentBridge actions on a - * target URL. + * Speaks MCP over stdio by default. The shared server (tools, resources, + * prompts, dispatcher) lives in ./server.ts; the transport adapter lives + * in ./transports/stdio.ts. This file is intentionally tiny — it picks a + * transport and routes top-level startup errors to stderr. + * + * v0.4.0 will add an opt-in HTTP transport (see + * docs/designs/http-mcp-transport-auth.md). Until then, stdio is the + * only runtime transport. */ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - GetPromptRequestSchema, - ListPromptsRequestSchema, - ListResourcesRequestSchema, - ListToolsRequestSchema, - ReadResourceRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { - callAction, - discoverManifest, - getAuditLog, - listActions, - scanAgentReadiness, -} from "./tools"; -import { PROMPTS, renderPrompt } from "./prompts"; -import { STATIC_RESOURCES, readResource } from "./resources"; - -const server = new Server( - { name: "agentbridge", version: "0.3.0" }, - { capabilities: { tools: {}, resources: {}, prompts: {} } }, -); - -// ── Tools ──────────────────────────────────────────────────────────── -const TOOLS = [ - { - name: "discover_manifest", - title: "Discover AgentBridge manifest", - description: - "Fetch and summarize an AgentBridge manifest from a URL. Use this first to understand what actions a site exposes.", - inputSchema: { - type: "object", - properties: { url: { type: "string", description: "Origin URL of the target app" } }, - required: ["url"], - }, - outputSchema: { - type: "object", - properties: { - name: { type: "string" }, - version: { type: "string" }, - baseUrl: { type: "string" }, - actionCount: { type: "number" }, - actionsByRisk: { type: "object" }, - }, - }, - }, - { - name: "scan_agent_readiness", - title: "Scan agent readiness", - description: - "Score how agent-ready a URL is. Returns a 0–100 score, structured checks, and grouped recommendations.", - inputSchema: { - type: "object", - properties: { url: { type: "string" } }, - required: ["url"], - }, - }, - { - name: "list_actions", - title: "List actions", - description: "List all actions in the AgentBridge manifest at a URL.", - inputSchema: { - type: "object", - properties: { url: { type: "string" } }, - required: ["url"], - }, - }, - { - name: "call_action", - title: "Call an AgentBridge action", - description: - "Invoke an AgentBridge action. Risky actions return a confirmationRequired response with a confirmationToken; the client must re-call with confirmationApproved: true AND the same confirmationToken to execute. Optional idempotencyKey replays prior results for the same key+input.", - inputSchema: { - type: "object", - properties: { - url: { type: "string" }, - actionName: { type: "string" }, - input: { type: "object" }, - confirmationApproved: { type: "boolean" }, - confirmationToken: { type: "string" }, - idempotencyKey: { type: "string" }, - }, - required: ["url", "actionName"], - }, - }, - { - name: "get_audit_log", - title: "Read audit log", - description: - "Read the local AgentBridge audit log. Filter by manifest URL with the optional `url` parameter.", - inputSchema: { - type: "object", - properties: { - url: { type: "string" }, - limit: { type: "number" }, - }, - }, - }, -]; - -server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); - -server.setRequestHandler(CallToolRequestSchema, async (req) => { - const { name, arguments: args = {} } = req.params; - try { - const result = await dispatchTool(name, args as Record); - // Return both readable text (for human-facing MCP UIs) AND structured - // content (for agent-side parsing). Older clients ignore structuredContent. - return { - content: [ - { type: "text", text: JSON.stringify(result, null, 2) }, - ], - structuredContent: result as Record, - }; - } catch (err) { - return { - isError: true, - content: [{ type: "text", text: `Error: ${(err as Error).message}` }], - }; - } -}); - -async function dispatchTool(name: string, args: Record) { - switch (name) { - case "discover_manifest": - return discoverManifest({ url: String(args.url) }); - case "scan_agent_readiness": - return scanAgentReadiness({ url: String(args.url) }); - case "list_actions": - return listActions({ url: String(args.url) }); - case "call_action": - return callAction({ - url: String(args.url), - actionName: String(args.actionName), - input: (args.input as Record | undefined) ?? {}, - confirmationApproved: args.confirmationApproved === true, - confirmationToken: - typeof args.confirmationToken === "string" ? args.confirmationToken : undefined, - idempotencyKey: - typeof args.idempotencyKey === "string" ? args.idempotencyKey : undefined, - }); - case "get_audit_log": - return getAuditLog({ - url: typeof args.url === "string" ? args.url : undefined, - limit: typeof args.limit === "number" ? args.limit : undefined, - }); - default: - throw new Error(`unknown tool: ${name}`); - } -} - -// ── Resources ──────────────────────────────────────────────────────── -server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: STATIC_RESOURCES, -})); - -server.setRequestHandler(ReadResourceRequestSchema, async (req) => { - const { uri } = req.params; - const result = await readResource(uri); - return { - contents: [{ uri: result.uri, mimeType: result.mimeType, text: result.text }], - }; -}); - -// ── Prompts ────────────────────────────────────────────────────────── -server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: PROMPTS.map((p) => ({ - name: p.name, - description: p.description, - arguments: p.arguments, - })), -})); - -server.setRequestHandler(GetPromptRequestSchema, async (req) => { - const { name, arguments: args = {} } = req.params; - // SDK GetPromptResult is a discriminated union — cast to the index-signature - // form so TS doesn't try to narrow into the task-result branch. - return renderPrompt(name, args as Record) as unknown as { - [x: string]: unknown; - description?: string; - messages: { role: "user"; content: { type: "text"; text: string } }[]; - }; -}); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); -} +import { runStdioServer } from "./transports/stdio"; -main().catch((err) => { +runStdioServer().catch((err) => { console.error("[agentbridge-mcp] fatal:", err); process.exit(1); }); diff --git a/apps/mcp-server/src/server.ts b/apps/mcp-server/src/server.ts new file mode 100644 index 0000000..a14c0e1 --- /dev/null +++ b/apps/mcp-server/src/server.ts @@ -0,0 +1,232 @@ +/* Shared MCP server factory. + * + * Both the stdio and (forthcoming) HTTP transports build the same MCP + * `Server` instance — same name/version, same tools, same resources, same + * prompts, same dispatch logic, same safety code path. Only the wire + * transport differs. This file is the single source of truth for what the + * AgentBridge MCP server exposes; the transport entries + * (`./transports/stdio.ts`, future `./transports/http.ts`) are thin + * adapters that build a transport and call `server.connect(transport)`. + * + * Background: docs/designs/http-mcp-transport-auth.md §9 ("Tool dispatch + * architecture") and docs/adr/0001-http-mcp-transport.md decision D9. + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { + callAction, + discoverManifest, + getAuditLog, + listActions, + scanAgentReadiness, +} from "./tools"; +import { PROMPTS, renderPrompt } from "./prompts"; +import { STATIC_RESOURCES, readResource } from "./resources"; + +// Server identity. Bumps in lockstep with @marmarlabs/agentbridge-mcp-server's +// package version on every release. +export const SERVER_NAME = "agentbridge"; +export const SERVER_VERSION = "0.3.0"; + +// ── Tool descriptors ───────────────────────────────────────────────── +// +// Shape mirrors what was inlined in index.ts before the v0.4.0 transport +// abstraction landed. Don't add transport-specific behavior here — both +// stdio and the forthcoming HTTP transport share this list verbatim. +export const TOOLS = [ + { + name: "discover_manifest", + title: "Discover AgentBridge manifest", + description: + "Fetch and summarize an AgentBridge manifest from a URL. Use this first to understand what actions a site exposes.", + inputSchema: { + type: "object", + properties: { url: { type: "string", description: "Origin URL of the target app" } }, + required: ["url"], + }, + outputSchema: { + type: "object", + properties: { + name: { type: "string" }, + version: { type: "string" }, + baseUrl: { type: "string" }, + actionCount: { type: "number" }, + actionsByRisk: { type: "object" }, + }, + }, + }, + { + name: "scan_agent_readiness", + title: "Scan agent readiness", + description: + "Score how agent-ready a URL is. Returns a 0–100 score, structured checks, and grouped recommendations.", + inputSchema: { + type: "object", + properties: { url: { type: "string" } }, + required: ["url"], + }, + }, + { + name: "list_actions", + title: "List actions", + description: "List all actions in the AgentBridge manifest at a URL.", + inputSchema: { + type: "object", + properties: { url: { type: "string" } }, + required: ["url"], + }, + }, + { + name: "call_action", + title: "Call an AgentBridge action", + description: + "Invoke an AgentBridge action. Risky actions return a confirmationRequired response with a confirmationToken; the client must re-call with confirmationApproved: true AND the same confirmationToken to execute. Optional idempotencyKey replays prior results for the same key+input.", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + actionName: { type: "string" }, + input: { type: "object" }, + confirmationApproved: { type: "boolean" }, + confirmationToken: { type: "string" }, + idempotencyKey: { type: "string" }, + }, + required: ["url", "actionName"], + }, + }, + { + name: "get_audit_log", + title: "Read audit log", + description: + "Read the local AgentBridge audit log. Filter by manifest URL with the optional `url` parameter.", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + limit: { type: "number" }, + }, + }, + }, +] as const; + +// Tool dispatcher. Same switch as before — kept transport-agnostic on +// purpose; auth and Origin checks (when HTTP lands) sit in front of the +// transport, never inside this dispatcher. +export async function dispatchTool( + name: string, + args: Record, +): Promise { + switch (name) { + case "discover_manifest": + return discoverManifest({ url: String(args.url) }); + case "scan_agent_readiness": + return scanAgentReadiness({ url: String(args.url) }); + case "list_actions": + return listActions({ url: String(args.url) }); + case "call_action": + return callAction({ + url: String(args.url), + actionName: String(args.actionName), + input: (args.input as Record | undefined) ?? {}, + confirmationApproved: args.confirmationApproved === true, + confirmationToken: + typeof args.confirmationToken === "string" ? args.confirmationToken : undefined, + idempotencyKey: + typeof args.idempotencyKey === "string" ? args.idempotencyKey : undefined, + }); + case "get_audit_log": + return getAuditLog({ + url: typeof args.url === "string" ? args.url : undefined, + limit: typeof args.limit === "number" ? args.limit : undefined, + }); + default: + throw new Error(`unknown tool: ${name}`); + } +} + +/** + * Build a fully-wired MCP `Server` instance for AgentBridge. + * + * Both transports (stdio today; HTTP in a follow-up PR) call this and + * differ only in how they connect the resulting server to a wire. + * + * Returned server has every request handler registered: + * - tools/list, tools/call → TOOLS + dispatchTool + * - resources/list, resources/read → STATIC_RESOURCES + readResource + * - prompts/list, prompts/get → PROMPTS + renderPrompt + * + * Behavior is preserved bit-for-bit from the pre-refactor inline wiring + * in index.ts. + */ +export function createMcpServer(): Server { + const server = new Server( + { name: SERVER_NAME, version: SERVER_VERSION }, + { capabilities: { tools: {}, resources: {}, prompts: {} } }, + ); + + // ── Tools ────────────────────────────────────────────────────────── + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args = {} } = req.params; + try { + const result = await dispatchTool(name, args as Record); + // Return both readable text (for human-facing MCP UIs) AND structured + // content (for agent-side parsing). Older clients ignore structuredContent. + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + structuredContent: result as Record, + }; + } catch (err) { + return { + isError: true, + content: [{ type: "text", text: `Error: ${(err as Error).message}` }], + }; + } + }); + + // ── Resources ────────────────────────────────────────────────────── + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: STATIC_RESOURCES, + })); + + server.setRequestHandler(ReadResourceRequestSchema, async (req) => { + const { uri } = req.params; + const result = await readResource(uri); + return { + contents: [{ uri: result.uri, mimeType: result.mimeType, text: result.text }], + }; + }); + + // ── Prompts ──────────────────────────────────────────────────────── + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: PROMPTS.map((p) => ({ + name: p.name, + description: p.description, + arguments: p.arguments, + })), + })); + + server.setRequestHandler(GetPromptRequestSchema, async (req) => { + const { name, arguments: args = {} } = req.params; + // SDK GetPromptResult is a discriminated union — cast to the index-signature + // form so TS doesn't try to narrow into the task-result branch. + return renderPrompt(name, args as Record) as unknown as { + [x: string]: unknown; + description?: string; + messages: { role: "user"; content: { type: "text"; text: string } }[]; + }; + }); + + return server; +} diff --git a/apps/mcp-server/src/tests/server-factory.test.ts b/apps/mcp-server/src/tests/server-factory.test.ts new file mode 100644 index 0000000..985d065 --- /dev/null +++ b/apps/mcp-server/src/tests/server-factory.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createMcpServer, TOOLS } from "../server"; + +/* The transport-abstraction refactor (v0.4.0 implementation PR 1) moved + * the MCP server wiring out of index.ts into a `createMcpServer()` + * factory. These tests pin the factory's behavior so future transports + * (HTTP) can plug in without forking the dispatcher. + * + * They use the SDK's InMemoryTransport pair so we can drive the server + * end-to-end through the real MCP wire protocol without needing a + * subprocess. The stdio-hygiene.test.ts subprocess test still proves + * the dist binary's stdout discipline is intact. + */ + +async function connectClientToFactory() { + const server = createMcpServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "factory-test", version: "0" }); + await client.connect(clientTransport); + return { server, client }; +} + +describe("createMcpServer (transport-agnostic factory)", () => { + it("exposes the five AgentBridge tools in the expected order", async () => { + const { server, client } = await connectClientToFactory(); + try { + const list = await client.listTools(); + expect(list.tools.map((t) => t.name)).toEqual([ + "discover_manifest", + "scan_agent_readiness", + "list_actions", + "call_action", + "get_audit_log", + ]); + // Sanity: the static TOOLS export and what the server returns over + // the wire describe the same set of tools. + expect(list.tools.map((t) => t.name)).toEqual( + TOOLS.map((t) => t.name), + ); + } finally { + await client.close(); + await server.close(); + } + }); + + it("exposes the four AgentBridge resources", async () => { + const { server, client } = await connectClientToFactory(); + try { + const list = await client.listResources(); + expect(list.resources.map((r) => r.uri).sort()).toEqual([ + "agentbridge://audit-log", + "agentbridge://manifest", + "agentbridge://readiness", + "agentbridge://spec/manifest-v0.1", + ]); + } finally { + await client.close(); + await server.close(); + } + }); + + it("exposes the four AgentBridge prompts", async () => { + const { server, client } = await connectClientToFactory(); + try { + const list = await client.listPrompts(); + expect(list.prompts.map((p) => p.name).sort()).toEqual([ + "explain_action_confirmation", + "generate_manifest_from_api", + "review_manifest_for_security", + "scan_app_for_agent_readiness", + ]); + } finally { + await client.close(); + await server.close(); + } + }); + + it("returns isError=true content (not a JSON-RPC error) when an unknown tool is called", async () => { + // Behavior preservation: pre-refactor, dispatchTool's `default:` throws + // were caught by the CallToolRequestSchema handler and converted into + // an isError response. The factory must keep that contract. + const { server, client } = await connectClientToFactory(); + try { + const result = (await client.callTool({ + name: "no_such_tool", + arguments: {}, + })) as { + isError?: boolean; + content?: { type: string; text: string }[]; + }; + expect(result.isError).toBe(true); + const text = result.content?.[0]?.text ?? ""; + expect(text).toMatch(/unknown tool: no_such_tool/); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/apps/mcp-server/src/transports/README.md b/apps/mcp-server/src/transports/README.md index 8e5153c..7d8c9dd 100644 --- a/apps/mcp-server/src/transports/README.md +++ b/apps/mcp-server/src/transports/README.md @@ -1,27 +1,31 @@ # `apps/mcp-server/src/transports/` -Reserved landing pad for the v0.4.0 HTTP MCP transport -implementation. Empty on purpose. - -## Why this directory exists now - -The v0.4.0 design PR (docs-only) creates this directory so the -follow-up implementation PRs have an obvious home and reviewers -have a concrete file path to look for. **Nothing in here is -imported from `index.ts`. There is no runtime effect.** - -## What lands here - -Per [`docs/designs/http-mcp-transport-auth.md`](../../../../docs/designs/http-mcp-transport-auth.md): - -1. **PR 1 — transport abstraction.** A `createMcpServer()` factory - in `../server.ts` (or similar). The two transport entry points - (`stdio.ts` and `http.ts`) live here. stdio is refactored to - call the factory; behavior unchanged. -2. **PR 2 — HTTP transport + bearer auth.** `http.ts` wires - `StreamableHTTPServerTransport` from `@modelcontextprotocol/sdk` - behind an auth + Origin + bind check. New env vars - (`AGENTBRIDGE_HTTP_*`) parsed in `../config.ts`. +Transport adapters for the AgentBridge MCP server. Each adapter is +a thin wrapper that builds the shared MCP server via +[`createMcpServer()`](../server.ts) and connects it to a wire +transport. **Auth, Origin validation, and host binding (when HTTP +lands) live in the adapter, not in the dispatcher.** + +## Current adapters + +| File | Status | Notes | +|---|---|---| +| [`stdio.ts`](stdio.ts) | shipping | Default. Wraps `StdioServerTransport`. Stdout = JSON-RPC; stderr = diagnostics. Verified by [`stdio-hygiene.test.ts`](../tests/stdio-hygiene.test.ts). | +| `http.ts` | not yet | Lands in v0.4.0 implementation PR 2. Will wrap `StreamableHTTPServerTransport` behind bearer auth + Origin allowlist + loopback-by-default bind. | + +## Migration plan + +Per [`docs/designs/http-mcp-transport-auth.md §13`](../../../../docs/designs/http-mcp-transport-auth.md#13-migration-plan): + +1. **PR 1 — transport abstraction.** ✅ landed. `createMcpServer()` + factory in `../server.ts`; this directory holds the stdio + adapter; `index.ts` is now a thin entry that calls + `runStdioServer()`. Zero behavior change. +2. **PR 2 — HTTP transport + bearer auth.** Adds `http.ts` here, + wires `StreamableHTTPServerTransport` from + `@modelcontextprotocol/sdk` behind an auth + Origin + bind + check. New env vars (`AGENTBRIDGE_HTTP_*`) parsed in + `../config.ts`. 3. **PR 3 — docs / examples / smoke tests.** Updates [`docs/security-configuration.md`](../../../../docs/security-configuration.md), [`docs/mcp-client-setup.md`](../../../../docs/mcp-client-setup.md), diff --git a/apps/mcp-server/src/transports/stdio.ts b/apps/mcp-server/src/transports/stdio.ts new file mode 100644 index 0000000..e514bde --- /dev/null +++ b/apps/mcp-server/src/transports/stdio.ts @@ -0,0 +1,31 @@ +/* stdio transport adapter for the AgentBridge MCP server. + * + * Builds the shared `Server` via `createMcpServer()` and connects it to a + * `StdioServerTransport`. This is the default and (for now) only runtime + * transport: HTTP arrives in a follow-up PR per + * docs/designs/http-mcp-transport-auth.md. + * + * Stdout discipline is preserved: the MCP SDK's StdioServerTransport + * only writes JSON-RPC bytes to stdout, and this module deliberately + * does not log anything (warnings/diagnostics already go to stderr from + * safety.ts and config.ts via process.stderr.write). The + * stdio-hygiene.test.ts subprocess test is the canonical proof. + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpServer } from "../server"; + +/** + * Build the shared MCP server, attach a stdio transport, and return once + * the connection is established. Resolves to the connected `Server` so + * callers can `close()` it during tests. + * + * Throws on transport setup failure; callers (i.e. `index.ts`) should + * route that to `console.error` + non-zero exit so the failure surfaces + * on stderr without polluting stdout. + */ +export async function runStdioServer(): Promise { + const server = createMcpServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); +} diff --git a/docs/designs/http-mcp-transport-auth.md b/docs/designs/http-mcp-transport-auth.md index 3d8a6cb..9573f29 100644 --- a/docs/designs/http-mcp-transport-auth.md +++ b/docs/designs/http-mcp-transport-auth.md @@ -508,10 +508,19 @@ to the implementation PRs that will follow this design. Releases land in this order: -1. **v0.4.0 design PR (this).** Docs only. No runtime change. -2. **v0.4.0 implementation PR 1 — transport abstraction.** Extract - `createMcpServer()` factory; refactor stdio entry to call it. - Zero behavior change. Adds the factory's tests if any. +1. **v0.4.0 design PR.** ✅ landed (PR + [#23](https://github.com/marmar9615-cloud/agentbridge-protocol/pull/23)). + Docs only. No runtime change. +2. **v0.4.0 implementation PR 1 — transport abstraction.** ✅ + landed. Extracted `createMcpServer()` factory in + [`apps/mcp-server/src/server.ts`](../../apps/mcp-server/src/server.ts); + added stdio adapter at + [`apps/mcp-server/src/transports/stdio.ts`](../../apps/mcp-server/src/transports/stdio.ts); + refactored + [`apps/mcp-server/src/index.ts`](../../apps/mcp-server/src/index.ts) + to a thin entry that calls `runStdioServer()`. Zero behavior + change; verified by the existing `stdio-hygiene.test.ts` + subprocess test plus a new in-memory factory test. 3. **v0.4.0 implementation PR 2 — HTTP transport + bearer auth.** Adds the HTTP entry path, env vars, host/auth/origin checks, and the Streamable HTTP wiring. All HTTP tests above land