From c6e0e49bed0ae1ec09773567ed139dad1b9d0936 Mon Sep 17 00:00:00 2001 From: eleven_qy <64311644+alph-cmky@users.noreply.github.com> Date: Sat, 30 May 2026 06:26:31 +0800 Subject: [PATCH] feat: add mcp tool registry --- apps/server/package.json | 6 + docs/development/task-backlog.md | 63 +++++++++ packages/mcp-server/package.json | 4 + packages/mcp-server/src/index.ts | 9 ++ packages/mcp-server/src/tool-registry.test.ts | 110 ++++++++++++++++ packages/mcp-server/src/tool-registry.ts | 123 ++++++++++++++++++ pnpm-lock.yaml | 9 +- 7 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-server/src/tool-registry.test.ts create mode 100644 packages/mcp-server/src/tool-registry.ts diff --git a/apps/server/package.json b/apps/server/package.json index 1e8863a..42ca3b3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,6 +3,12 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.js" + } + }, "scripts": { "dev": "echo \"@agentdeck/server dev server is not scaffolded yet\"", "build": "tsc -p tsconfig.json", diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index dbcc11c..c38af1a 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -670,3 +670,66 @@ Status markers: **Acceptance Criteria:** - A contributor can run the web app locally and inspect the MVP UI in a browser. + +## Phase 12: MCP Tooling + +### AD-1201: Define MCP Tool Registry + +**Goal:** Provide controlled MCP-facing tool contracts for reading local AgentDeck definitions. + +**Files:** + +- Create `packages/mcp-server/src/tool-registry.ts` +- Create `packages/mcp-server/src/tool-registry.test.ts` +- Modify `packages/mcp-server/src/index.ts` + +**Tasks:** + +- [x] Define tool metadata with name, description, input schema, and handler. +- [x] Add read-only tools for listing agents, listing workflows, and getting workflow details. +- [x] Reject unknown tool names with a clear error. +- [x] Commit with message `feat: add mcp tool registry`. + +**Acceptance Criteria:** + +- MCP clients can discover and call read-only AgentDeck tools without direct access to persistence internals. + +### AD-1202: Add MCP Patch Proposal Tools + +**Goal:** Expose controlled tools for proposing and previewing workflow patches. + +**Files:** + +- Modify `packages/mcp-server/src/tool-registry.ts` +- Create or modify `packages/mcp-server/src/patch-tools.test.ts` + +**Tasks:** + +- [ ] Add a tool to propose workflow patches through `patch-service`. +- [ ] Add a tool to preview patch diff summaries and validation results. +- [ ] Ensure tools never apply patches directly. +- [ ] Commit with message `feat: add mcp patch proposal tools`. + +**Acceptance Criteria:** + +- Agent clients can propose workflow changes but cannot bypass validation or approval. + +### AD-1203: Add MCP Approval Apply Tool + +**Goal:** Expose the approved patch application path through a controlled MCP tool. + +**Files:** + +- Modify `packages/mcp-server/src/tool-registry.ts` +- Create or modify `packages/mcp-server/src/approval-tools.test.ts` + +**Tasks:** + +- [ ] Add a tool that applies only approved patch proposals. +- [ ] Surface stale version and validation failures as tool errors. +- [ ] Record audit entries for apply attempts. +- [ ] Commit with message `feat: add mcp approved patch apply tool`. + +**Acceptance Criteria:** + +- Approved patches can be applied from MCP while unapproved or stale patches are rejected. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index e314212..443f0ec 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -16,5 +16,9 @@ "lint": "echo \"@agentdeck/mcp-server lint is not scaffolded yet\"", "typecheck": "tsc -p tsconfig.json --noEmit", "format": "echo \"@agentdeck/mcp-server format is not scaffolded yet\"" + }, + "dependencies": { + "@agentdeck/server": "workspace:*", + "@agentdeck/workflow-core": "workspace:*" } } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index b077d62..710a7ab 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1 +1,10 @@ export const mcpServerPackageName = "@agentdeck/mcp-server"; + +export { createAgentDeckToolRegistry } from "./tool-registry.js"; +export type { + AgentDeckMcpTool, + AgentDeckMcpToolResult, + AgentDeckToolRegistry, + CreateAgentDeckToolRegistryOptions, + McpToolInputSchema, +} from "./tool-registry.js"; diff --git a/packages/mcp-server/src/tool-registry.test.ts b/packages/mcp-server/src/tool-registry.test.ts new file mode 100644 index 0000000..7bcaf8c --- /dev/null +++ b/packages/mcp-server/src/tool-registry.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { createAgentDeckToolRegistry } from "./tool-registry.js"; + +const agent = { + id: "reviewer", + name: "Reviewer", + description: "Reviews code", + prompt: "Review the current diff.", + model: "gpt-5", + tools: ["repo.read"], + permissions: { + read: true, + write: false, + install: false, + arbitraryCommands: false, + commit: false, + push: false, + }, + memoryScope: "workspace", + runtimePreference: "codex", + workspaceRoot: "/tmp/agentdeck", + createdAt: "2026-05-30T00:00:00.000Z", + updatedAt: "2026-05-30T00:00:00.000Z", +} as const; + +const workflow = { + id: "review-flow", + name: "Review Flow", + version: 1, + status: "draft", + variables: {}, + permissions: { + read: true, + write: false, + install: false, + arbitraryCommands: false, + commit: false, + push: false, + }, + nodes: [ + { id: "start", type: "start", label: "Start" }, + { id: "agent-review", type: "agent", label: "Review", agentId: "reviewer" }, + { id: "end", type: "end", label: "End" }, + ], + edges: [ + { id: "edge-start-review", from: "start", to: "agent-review" }, + { id: "edge-review-end", from: "agent-review", to: "end" }, + ], + createdAt: "2026-05-30T00:00:00.000Z", + updatedAt: "2026-05-30T00:00:00.000Z", +} as const; + +describe("createAgentDeckToolRegistry", () => { + it("discovers read-only AgentDeck MCP tools", () => { + const registry = createAgentDeckToolRegistry({ + agentRegistry: { list: async () => [agent] }, + workflowRegistry: { list: async () => [workflow], get: async () => workflow }, + }); + + expect(registry.listTools()).toEqual([ + expect.objectContaining({ + name: "agentdeck.listAgents", + description: "List configured AgentDeck agents.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }), + expect.objectContaining({ + name: "agentdeck.listWorkflows", + description: "List persisted AgentDeck workflows.", + }), + expect.objectContaining({ + name: "agentdeck.getWorkflow", + description: "Get one persisted AgentDeck workflow by ID.", + }), + ]); + }); + + it("calls read-only tools without exposing persistence internals", async () => { + const registry = createAgentDeckToolRegistry({ + agentRegistry: { list: async () => [agent] }, + workflowRegistry: { list: async () => [workflow], get: async () => workflow }, + }); + + await expect(registry.callTool("agentdeck.listAgents", {})).resolves.toEqual({ + agents: [agent], + }); + await expect(registry.callTool("agentdeck.listWorkflows", {})).resolves.toEqual({ + workflows: [workflow], + }); + await expect( + registry.callTool("agentdeck.getWorkflow", { id: "review-flow" }), + ).resolves.toEqual({ + workflow, + }); + }); + + it("returns a clear error for unknown tools and missing workflows", async () => { + const registry = createAgentDeckToolRegistry({ + agentRegistry: { list: async () => [] }, + workflowRegistry: { list: async () => [], get: async () => undefined }, + }); + + await expect(registry.callTool("agentdeck.nope", {})).rejects.toThrow( + 'MCP tool "agentdeck.nope" is not registered.', + ); + await expect(registry.callTool("agentdeck.getWorkflow", { id: "missing" })).rejects.toThrow( + 'Workflow "missing" was not found.', + ); + }); +}); diff --git a/packages/mcp-server/src/tool-registry.ts b/packages/mcp-server/src/tool-registry.ts new file mode 100644 index 0000000..3afa91f --- /dev/null +++ b/packages/mcp-server/src/tool-registry.ts @@ -0,0 +1,123 @@ +import type { + AgentRegistryService, + PersistedAgentDefinition, + WorkflowRegistryService, +} from "@agentdeck/server"; +import type { WorkflowDefinition } from "@agentdeck/workflow-core"; + +export interface McpToolInputSchema { + readonly type: "object"; + readonly properties: Readonly>; + readonly required?: readonly string[]; + readonly additionalProperties: boolean; +} + +export interface AgentDeckMcpTool { + readonly name: string; + readonly description: string; + readonly inputSchema: McpToolInputSchema; +} + +export type AgentDeckMcpToolResult = + | { readonly agents: readonly PersistedAgentDefinition[] } + | { readonly workflows: readonly WorkflowDefinition[] } + | { readonly workflow: WorkflowDefinition }; + +export interface AgentDeckToolRegistry { + readonly listTools: () => readonly AgentDeckMcpTool[]; + readonly callTool: (name: string, input: unknown) => Promise; +} + +export interface CreateAgentDeckToolRegistryOptions { + readonly agentRegistry: Pick; + readonly workflowRegistry: Pick; +} + +type ToolHandler = (input: unknown) => Promise; + +interface ToolDefinition extends AgentDeckMcpTool { + readonly handler: ToolHandler; +} + +const EMPTY_INPUT_SCHEMA: McpToolInputSchema = { + type: "object", + properties: {}, + additionalProperties: false, +}; + +const GET_WORKFLOW_INPUT_SCHEMA: McpToolInputSchema = { + type: "object", + properties: { + id: { type: "string" }, + }, + required: ["id"], + additionalProperties: false, +}; + +export function createAgentDeckToolRegistry( + options: CreateAgentDeckToolRegistryOptions, +): AgentDeckToolRegistry { + const tools: readonly ToolDefinition[] = [ + { + name: "agentdeck.listAgents", + description: "List configured AgentDeck agents.", + inputSchema: EMPTY_INPUT_SCHEMA, + async handler() { + return { agents: await options.agentRegistry.list() }; + }, + }, + { + name: "agentdeck.listWorkflows", + description: "List persisted AgentDeck workflows.", + inputSchema: EMPTY_INPUT_SCHEMA, + async handler() { + return { workflows: await options.workflowRegistry.list() }; + }, + }, + { + name: "agentdeck.getWorkflow", + description: "Get one persisted AgentDeck workflow by ID.", + inputSchema: GET_WORKFLOW_INPUT_SCHEMA, + async handler(input) { + const id = readStringInput(input, "id"); + const workflow = await options.workflowRegistry.get(id); + if (!workflow) { + throw new Error(`Workflow "${id}" was not found.`); + } + + return { workflow }; + }, + }, + ]; + const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); + + return { + listTools() { + return tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })); + }, + async callTool(name, input) { + const tool = toolsByName.get(name); + if (!tool) { + throw new Error(`MCP tool "${name}" is not registered.`); + } + + return await tool.handler(input); + }, + }; +} + +function readStringInput(input: unknown, key: string): string { + if (!isRecord(input) || typeof input[key] !== "string" || !input[key].trim()) { + throw new Error(`MCP tool input "${key}" must be a non-empty string.`); + } + + return input[key].trim(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98403d7..e276a58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,14 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.15) - packages/mcp-server: {} + packages/mcp-server: + dependencies: + '@agentdeck/server': + specifier: workspace:* + version: link:../../apps/server + '@agentdeck/workflow-core': + specifier: workspace:* + version: link:../workflow-core packages/runtime-adapters: {}