Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions docs/development/task-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
}
}
9 changes: 9 additions & 0 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
110 changes: 110 additions & 0 deletions packages/mcp-server/src/tool-registry.test.ts
Original file line number Diff line number Diff line change
@@ -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.',
);
});
});
123 changes: 123 additions & 0 deletions packages/mcp-server/src/tool-registry.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
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<AgentDeckMcpToolResult>;
}

export interface CreateAgentDeckToolRegistryOptions {
readonly agentRegistry: Pick<AgentRegistryService, "list">;
readonly workflowRegistry: Pick<WorkflowRegistryService, "list" | "get">;
}

type ToolHandler = (input: unknown) => Promise<AgentDeckMcpToolResult>;

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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
9 changes: 8 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading