diff --git a/README.md b/README.md index 611f213..07efed7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are - Opaque agent tokens are generated once and stored only as SHA-256 hashes. - Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls. - Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter. -- MCP and Tasker write execution are documented but not implemented yet. +- MCP JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints. +- Tool execution calls the real Tasker internal adapter; no fake Tasker storage exists in Gateway. +- Local real e2e smoke verifies Gateway -> MCP -> Tasker runtime writes. ## Local development @@ -44,6 +46,7 @@ Useful checks: ```bash npm run check npm run build +npm run smoke:mcp npm run smoke:gateway curl http://127.0.0.1:4100/readyz curl http://127.0.0.1:4100/api/v1/meta/capabilities @@ -70,15 +73,52 @@ curl http://127.0.0.1:4100/api/v1/agent-session \ Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added. +Call MCP tools through the JSON-RPC endpoint: + +```bash +curl -sS http://127.0.0.1:4100/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2025-06-18' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{}}' | jq +``` + ## Local testing strategy No fake Tasker storage is embedded into Agent Gateway. Local verification is split into product layers: -1. `npm run smoke:gateway` verifies real Agent Gateway persistence, bearer token auth, scope checks, grant checks, and the boundary before Tasker calls. -2. Full localhost e2e starts after Tasker implements `/api/internal/nodedc/agent/...` adapter. Then the same Gateway tool endpoints call the real local Tasker runtime. -3. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced. +1. `npm run smoke:mcp` verifies MCP initialize, tool listing, bearer token auth, scope checks, grant checks, and the Tasker boundary. +2. `npm run smoke:gateway` verifies the REST compatibility boundary over the same tool execution path. +3. `npm run smoke:e2e` verifies REST tool endpoints against the real local Tasker runtime. +4. `npm run smoke:mcp:e2e` verifies MCP tool calls against the real local Tasker runtime. +5. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced. + +Example real localhost MCP e2e: + +```bash +TOKEN=$(python3 - <<'PY' +from pathlib import Path +for line in Path('/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/plane.env').read_text().splitlines(): + if line.startswith('NODEDC_INTERNAL_ACCESS_TOKEN=') or line.startswith('PLANE_NODEDC_ACCESS_TOKEN='): + value = line.split('=', 1)[1].strip().strip('"').strip("'") + if value: + print(value) + break +PY +) + +DATABASE_URL='postgres://nodedc_agent_gateway:replace-with-local-postgres-password@localhost:54100/nodedc_agent_gateway' \ +NODE_ENV=development \ +LOG_LEVEL=silent \ +NODEDC_TASKER_INTERNAL_URL='http://localhost:8090' \ +NODEDC_INTERNAL_ACCESS_TOKEN="$TOKEN" \ +SMOKE_WORKSPACE_SLUG='nodedc' \ +SMOKE_PROJECT_ID='' \ +npm run smoke:mcp:e2e +``` Current Tasker internal adapter contract expected by Gateway: diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index ec4b223..31f8031 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -23,7 +23,7 @@ Exit criteria: ## Phase 1. Agent Gateway skeleton -Status: in progress. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, product tool endpoints, local Postgres compose, and Gateway smoke checks are implemented. +Status: done in `e95cb3a`, `112522c`, `14c5f49`, `9f40207`, and the MCP transport slice. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, product tool endpoints, local Postgres compose, and Gateway smoke checks are implemented. Create standalone service with: @@ -36,7 +36,7 @@ Create standalone service with: - opaque token hashing; - idempotency-key storage. -No Tasker writes yet. +Tasker writes are available through the narrow internal adapter; Gateway still must not call raw Plane routes. ## Phase 2. Launcher entitlement projection @@ -89,6 +89,8 @@ Acceptance: - adapter rejects delete/archive; - adapter validates labels/states/assignees. +Status: initial product slice done in Tasker commit `2ae353c`. Implemented project resolution/context, issue search/create/update/move/comment/label/assign, agent bot actor metadata, and internal token auth. `add_existing_project_member` remains planned behind the explicit `project:member:add_existing` scope. + ## Phase 5. MCP server Agent Gateway changes: @@ -109,6 +111,8 @@ Acceptance: - local Codex can move card state; - local Codex cannot delete/archive. +Status: initial product slice implemented. `/mcp` supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call`. REST product endpoints and MCP tools share the same runtime, scope checks, grant checks, and Tasker adapter calls. `npm run smoke:mcp:e2e` verifies real local Tasker writes. + ## Phase 6. Agent identity Tasker/Gateway integration: diff --git a/docs/MCP_TOOLS_CONTRACT.md b/docs/MCP_TOOLS_CONTRACT.md index 6f33b19..e4a15f7 100644 --- a/docs/MCP_TOOLS_CONTRACT.md +++ b/docs/MCP_TOOLS_CONTRACT.md @@ -8,6 +8,14 @@ The current Tasker / Plane fork does not expose a dedicated MCP server. It expos Codex should not call generic Tasker REST directly. +Current implementation status: + +- Agent Gateway exposes `/mcp` as JSON-RPC over HTTP. +- Implemented MCP methods: `initialize`, `ping`, `tools/list`, `tools/call`. +- `tools/list` returns only tools allowed by the authenticated agent session scopes. +- `tools/call` uses the same product runtime as REST tool endpoints. +- Server-sent event streaming is intentionally not required for the first product slice. + ## Authentication MCP clients authenticate to Agent Gateway with an opaque agent token. @@ -18,6 +26,20 @@ Recommended transport options: - local stdio connector later if useful; - REST fallback for non-MCP clients. +Current route: + +```text +POST /mcp +``` + +Required request headers for authenticated tool calls: + +```text +Authorization: Bearer +Accept: application/json, text/event-stream +MCP-Protocol-Version: 2025-06-18 +``` + Token rules: - token is opaque; diff --git a/docs/TASKER_API_AUDIT.md b/docs/TASKER_API_AUDIT.md index 9bb41d2..91fc525 100644 --- a/docs/TASKER_API_AUDIT.md +++ b/docs/TASKER_API_AUDIT.md @@ -140,6 +140,30 @@ Add internal endpoints under a namespace such as: /api/internal/nodedc/agent/... ``` +Current implemented adapter routes: + +```text +POST /api/internal/nodedc/agent/projects/resolve +GET /api/internal/nodedc/agent/projects/:project_id/context +GET /api/internal/nodedc/agent/issues?project_id=... +POST /api/internal/nodedc/agent/issues +PATCH /api/internal/nodedc/agent/issues/:issue_id +POST /api/internal/nodedc/agent/issues/:issue_id/move +POST /api/internal/nodedc/agent/issues/:issue_id/comments +PUT /api/internal/nodedc/agent/issues/:issue_id/labels +PUT /api/internal/nodedc/agent/issues/:issue_id/assignees +``` + +The implemented adapter uses NODE.DC internal bearer auth and receives normalized agent metadata in headers: + +```text +X-NODEDC-Agent-Id +X-NODEDC-Agent-Owner-User-Id +X-NODEDC-Agent-Token-Id +``` + +The current adapter creates or reuses a dedicated bot actor with email `agent+@agents.nodedc.local` and `bot_type=nodedc_codex_agent`. + These endpoints must use `NODEDC_INTERNAL_ACCESS_TOKEN` / `PLANE_NODEDC_ACCESS_TOKEN` style auth and must be callable only from Agent Gateway. Suggested adapter endpoints: diff --git a/package.json b/package.json index 89c3f30..25eedbd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "migrate:dist": "node dist/scripts/migrate.js", "smoke:e2e": "tsx src/scripts/smoke-e2e.ts", "smoke:gateway": "tsx src/scripts/smoke-gateway.ts", + "smoke:mcp:e2e": "tsx src/scripts/smoke-mcp-e2e.ts", + "smoke:mcp": "tsx src/scripts/smoke-mcp.ts", "start": "node dist/server.js" }, "dependencies": { diff --git a/src/app.ts b/src/app.ts index 00258e0..9b8719b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js"; import { AgentsRepository } from "./repositories/agents.js"; import { registerAgentRoutes } from "./routes/agents.js"; import { registerHealthRoutes } from "./routes/health.js"; +import { registerMcpRoutes } from "./routes/mcp.js"; import { registerToolRoutes } from "./routes/tools.js"; import { ForbiddenError } from "./security/authorization.js"; import { UnauthorizedError } from "./security/bearer.js"; @@ -105,6 +106,7 @@ export async function buildApp(config: AppConfig): Promise { await registerHealthRoutes(app, config, pool); await registerAgentRoutes(app, { agentsRepository }); await registerToolRoutes(app, { agentsRepository, taskerClient }); + await registerMcpRoutes(app, { agentsRepository, taskerClient }); return app; } diff --git a/src/mcp/tool-runtime.ts b/src/mcp/tool-runtime.ts new file mode 100644 index 0000000..f449a01 --- /dev/null +++ b/src/mcp/tool-runtime.ts @@ -0,0 +1,453 @@ +import { z } from "zod"; + +import type { AgentScope } from "../domain/scopes.js"; +import { structuredBlocksSchema } from "../domain/structured-blocks.js"; +import type { AgentSessionRecord } from "../repositories/agents.js"; +import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js"; +import type { TaskerClient } from "../tasker/client.js"; + +type JsonSchema = Record; + +export type McpToolRuntimeDefinition = { + name: string; + title: string; + description: string; + requiredScopes: AgentScope[]; + inputSchema: JsonSchema; + annotations?: Record; +}; + +export type McpToolResult = { + content: Array<{ + type: "text"; + text: string; + }>; + structuredContent?: unknown; + isError?: boolean; +}; + +type ExecuteToolDeps = { + taskerClient: TaskerClient; +}; + +const emptyInputSchema = { + type: "object", + additionalProperties: false, +}; + +const projectInputSchema = { + type: "object", + properties: { + project_id: { type: "string" }, + workspace_slug: { type: "string" }, + }, + required: ["project_id"], + additionalProperties: false, +}; + +const projectAndIssueInputSchema = { + type: "object", + properties: { + issue_id: { type: "string" }, + project_id: { type: "string" }, + workspace_slug: { type: "string" }, + }, + required: ["issue_id", "project_id"], + additionalProperties: false, +}; + +const structuredBlocksJsonSchema = { + type: "array", + items: { + oneOf: [ + { + type: "object", + properties: { + id: { type: "string" }, + type: { const: "text" }, + title: { type: "string" }, + body: { type: "string" }, + }, + required: ["id", "type"], + additionalProperties: false, + }, + { + type: "object", + properties: { + id: { type: "string" }, + type: { const: "checker" }, + title: { type: "string" }, + items: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + text: { type: "string" }, + checked: { type: "boolean" }, + }, + required: ["id", "text"], + additionalProperties: false, + }, + }, + }, + required: ["id", "type"], + additionalProperties: false, + }, + ], + }, +}; + +export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ + { + name: "tasker_get_agent_instructions", + title: "Get Agent Instructions", + description: "Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.", + requiredScopes: ["workspace:read"], + inputSchema: emptyInputSchema, + annotations: { readOnlyHint: true }, + }, + { + name: "tasker_list_projects", + title: "List Granted Projects", + description: "List Tasker projects granted to the current agent.", + requiredScopes: ["project:read"], + inputSchema: emptyInputSchema, + annotations: { readOnlyHint: true }, + }, + { + name: "tasker_get_project_context", + title: "Get Project Context", + description: "Return states, labels, members, and card-writing context for one granted project.", + requiredScopes: ["project:read"], + inputSchema: projectInputSchema, + annotations: { readOnlyHint: true }, + }, + { + name: "tasker_search_issues", + title: "Search Issues", + description: "Search work items inside one granted Tasker project.", + requiredScopes: ["issue:read"], + inputSchema: { + ...projectInputSchema, + properties: { + ...projectInputSchema.properties, + query: { type: "string" }, + }, + }, + annotations: { readOnlyHint: true }, + }, + { + name: "tasker_create_issue", + title: "Create Issue", + description: "Create a Tasker card with optional NODE.DC structured text/checker blocks.", + requiredScopes: ["issue:create"], + inputSchema: { + type: "object", + properties: { + project_id: { type: "string" }, + workspace_slug: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] }, + structured_blocks: structuredBlocksJsonSchema, + }, + required: ["project_id", "title"], + additionalProperties: false, + }, + annotations: { destructiveHint: false, idempotentHint: false }, + }, + { + name: "tasker_update_issue", + title: "Update Issue", + description: "Patch allowed issue fields without delete, archive, or project transfer.", + requiredScopes: ["issue:update"], + inputSchema: { + type: "object", + properties: { + issue_id: { type: "string" }, + project_id: { type: "string" }, + workspace_slug: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] }, + structured_blocks: structuredBlocksJsonSchema, + }, + required: ["issue_id", "project_id"], + additionalProperties: false, + }, + annotations: { destructiveHint: false, idempotentHint: false }, + }, + { + name: "tasker_update_structured_blocks", + title: "Update Structured Blocks", + description: "Replace NODE.DC structured text/checker blocks in an issue detail layout.", + requiredScopes: ["issue:update", "issue:structured_blocks:write"], + inputSchema: { + ...projectAndIssueInputSchema, + properties: { + ...projectAndIssueInputSchema.properties, + structured_blocks: structuredBlocksJsonSchema, + }, + required: ["issue_id", "project_id", "structured_blocks"], + }, + annotations: { destructiveHint: false, idempotentHint: false }, + }, + { + name: "tasker_move_issue", + title: "Move Issue", + description: "Move an issue to an existing state in the same granted project.", + requiredScopes: ["issue:move"], + inputSchema: { + ...projectAndIssueInputSchema, + properties: { + ...projectAndIssueInputSchema.properties, + state_id: { type: "string" }, + }, + required: ["issue_id", "project_id", "state_id"], + }, + annotations: { destructiveHint: false, idempotentHint: false }, + }, + { + name: "tasker_append_comment", + title: "Append Comment", + description: "Append a comment to a granted issue.", + requiredScopes: ["issue:comment"], + inputSchema: { + ...projectAndIssueInputSchema, + properties: { + ...projectAndIssueInputSchema.properties, + body: { type: "string" }, + }, + required: ["issue_id", "project_id", "body"], + }, + annotations: { destructiveHint: false, idempotentHint: false }, + }, + { + name: "tasker_set_issue_labels", + title: "Set Issue Labels", + description: "Replace issue labels with existing labels from the granted project.", + requiredScopes: ["issue:label"], + inputSchema: { + ...projectAndIssueInputSchema, + properties: { + ...projectAndIssueInputSchema.properties, + label_ids: { type: "array", items: { type: "string" } }, + }, + required: ["issue_id", "project_id", "label_ids"], + }, + annotations: { destructiveHint: false, idempotentHint: true }, + }, + { + name: "tasker_assign_issue", + title: "Assign Issue", + description: "Replace issue assignees with existing members of the granted project.", + requiredScopes: ["issue:assign"], + inputSchema: { + ...projectAndIssueInputSchema, + properties: { + ...projectAndIssueInputSchema.properties, + member_ids: { type: "array", items: { type: "string" } }, + }, + required: ["issue_id", "project_id", "member_ids"], + }, + annotations: { destructiveHint: false, idempotentHint: true }, + }, +]; + +const emptyArgsSchema = z.object({}).default({}); +const projectArgsSchema = z.object({ + project_id: z.string().min(1), + workspace_slug: z.string().min(1).nullish(), +}); +const searchIssuesArgsSchema = projectArgsSchema.extend({ + query: z.string().min(1).optional(), +}); +const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]); +const createIssueArgsSchema = z.object({ + project_id: z.string().min(1), + workspace_slug: z.string().min(1).nullish(), + title: z.string().min(1).max(500), + description: z.string().max(20000).optional(), + priority: prioritySchema.optional(), + structured_blocks: structuredBlocksSchema.optional(), +}); +const issueArgsSchema = z.object({ + issue_id: z.string().min(1), + project_id: z.string().min(1), + workspace_slug: z.string().min(1).nullish(), +}); +const updateIssueArgsSchema = issueArgsSchema.extend({ + title: z.string().min(1).max(500).optional(), + description: z.string().max(20000).optional(), + priority: prioritySchema.optional(), + structured_blocks: structuredBlocksSchema.optional(), +}); +const structuredBlocksArgsSchema = issueArgsSchema.extend({ + structured_blocks: structuredBlocksSchema, +}); +const moveIssueArgsSchema = issueArgsSchema.extend({ + state_id: z.string().min(1), +}); +const commentArgsSchema = issueArgsSchema.extend({ + body: z.string().min(1).max(20000), +}); +const labelsArgsSchema = issueArgsSchema.extend({ + label_ids: z.array(z.string().min(1)).default([]), +}); +const assigneesArgsSchema = issueArgsSchema.extend({ + member_ids: z.array(z.string().min(1)).default([]), +}); + +export function getToolsForSession(session: AgentSessionRecord): McpToolRuntimeDefinition[] { + return mcpRuntimeTools.filter((tool) => tool.requiredScopes.every((scope) => hasScope(session, scope))); +} + +export async function executeMcpTool( + session: AgentSessionRecord, + name: string, + rawArguments: unknown, + deps: ExecuteToolDeps +): Promise { + const args = rawArguments ?? {}; + + switch (name) { + case "tasker_get_agent_instructions": + emptyArgsSchema.parse(args); + requireScope(session, "workspace:read"); + return asToolResult(buildAgentInstructions(session)); + case "tasker_list_projects": + emptyArgsSchema.parse(args); + requireScope(session, "project:read"); + return asToolResult(await deps.taskerClient.listGrantedProjects(session)); + case "tasker_get_project_context": { + const input = projectArgsSchema.parse(args); + requireScope(session, "project:read"); + requireProjectGrant(session, { projectId: input.project_id, workspaceSlug: input.workspace_slug }); + return asToolResult(await deps.taskerClient.getProjectContext(session, input.project_id, input.workspace_slug)); + } + case "tasker_search_issues": { + const input = searchIssuesArgsSchema.parse(args); + requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug); + return asToolResult(await deps.taskerClient.listIssues(session, input.project_id, input.workspace_slug, input.query)); + } + case "tasker_create_issue": { + const input = createIssueArgsSchema.parse(args); + requireToolAccess(session, "issue:create", input.project_id, input.workspace_slug); + return asToolResult(await deps.taskerClient.createIssue(session, input)); + } + case "tasker_update_issue": { + const input = updateIssueArgsSchema.parse(args); + if (input.structured_blocks) { + requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]); + } else { + requireToolAccess(session, "issue:update", input.project_id, input.workspace_slug); + } + return asToolResult(await deps.taskerClient.updateIssue(session, input.issue_id, input)); + } + case "tasker_update_structured_blocks": { + const input = structuredBlocksArgsSchema.parse(args); + requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]); + return asToolResult( + await deps.taskerClient.updateIssue(session, input.issue_id, { + project_id: input.project_id, + workspace_slug: input.workspace_slug, + structured_blocks: input.structured_blocks, + }) + ); + } + case "tasker_move_issue": { + const input = moveIssueArgsSchema.parse(args); + requireToolAccess(session, "issue:move", input.project_id, input.workspace_slug); + return asToolResult(await deps.taskerClient.moveIssue(session, input.issue_id, input)); + } + case "tasker_append_comment": { + const input = commentArgsSchema.parse(args); + requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug); + return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input)); + } + case "tasker_set_issue_labels": { + const input = labelsArgsSchema.parse(args); + requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug); + return asToolResult(await deps.taskerClient.setLabels(session, input.issue_id, input)); + } + case "tasker_assign_issue": { + const input = assigneesArgsSchema.parse(args); + requireToolAccess(session, "issue:assign", input.project_id, input.workspace_slug); + return asToolResult(await deps.taskerClient.assignIssue(session, input.issue_id, input)); + } + default: + throw new Error(`Unknown MCP tool: ${name}`); + } +} + +function hasScope(session: AgentSessionRecord, scope: AgentScope): boolean { + return session.grants.some((grant) => grant.scopes.includes(scope)); +} + +function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void { + requireProjectScopes(session, projectId, workspaceSlug, [scope]); +} + +function requireProjectScopes( + session: AgentSessionRecord, + projectId: string, + workspaceSlug: string | null | undefined, + scopes: AgentScope[] +): void { + for (const scope of scopes) { + requireScope(session, scope); + } + + const grant = requireProjectGrant(session, { projectId, workspaceSlug }); + + for (const scope of scopes) { + if (!grant.scopes.includes(scope)) { + throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`); + } + } +} + +function asToolResult(payload: unknown): McpToolResult { + return { + content: [ + { + type: "text", + text: JSON.stringify(payload, null, 2), + }, + ], + structuredContent: payload, + isError: false, + }; +} + +function buildAgentInstructions(session: AgentSessionRecord): Record { + return { + agent: { + id: session.agent.id, + display_name: session.agent.displayName, + owner_user_id: session.agent.ownerUserId, + }, + grants: session.grants.map((grant) => ({ + workspace_slug: grant.workspaceSlug, + project_id: grant.projectId, + mode: grant.mode, + scopes: grant.scopes, + })), + rules: { + card_structure: [ + "Keep the main issue description concise and conceptual.", + "Use structured text blocks for current architecture, planned architecture, and implementation notes.", + "Use checker blocks for short verifiable phase items.", + "After implementation, add a factual implementation block with files touched and validation performed.", + ], + hard_limits: [ + "Do not delete or archive issues.", + "Do not create projects, labels, states, workspace invites, or workspace settings changes.", + "Only assign existing project members.", + "Only use projects and workspaces present in effective grants.", + ], + reporting_mode: "If a grant has mode=reporting, keep issue status and comments up to date without pretending to enforce unmanaged local Codex execution.", + }, + }; +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 6ce1b98..3337874 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,84 +1,24 @@ import type { AgentScope } from "../domain/scopes.js"; +import { mcpRuntimeTools } from "./tool-runtime.js"; export type McpToolDefinition = { name: string; description: string; requiredScopes: AgentScope[]; - status: "planned"; + status: "implemented" | "planned"; }; export const mcpToolDefinitions: McpToolDefinition[] = [ - { - name: "tasker_get_agent_instructions", - description: "Return effective NODE.DC Tasker card rules, allowed projects, scopes, and reporting expectations.", - requiredScopes: ["workspace:read"], - status: "planned", - }, - { - name: "tasker_list_projects", - description: "List Tasker projects granted to the current agent.", - requiredScopes: ["project:read"], - status: "planned", - }, - { - name: "tasker_get_project_context", - description: "Return states, labels, members, and card-writing rules for one granted project.", - requiredScopes: ["project:read"], - status: "planned", - }, - { - name: "tasker_search_issues", - description: "Search work items inside granted projects.", - requiredScopes: ["issue:read"], - status: "planned", - }, - { - name: "tasker_create_issue", - description: "Create a Tasker work item with NODE.DC structured content support.", - requiredScopes: ["issue:create"], - status: "planned", - }, - { - name: "tasker_update_issue", - description: "Patch allowed issue fields without delete, archive, or project transfer.", - requiredScopes: ["issue:update"], - status: "planned", - }, - { - name: "tasker_move_issue", - description: "Move an issue to an existing state in the same granted project.", - requiredScopes: ["issue:move"], - status: "planned", - }, - { - name: "tasker_set_issue_labels", - description: "Apply existing project labels to an issue.", - requiredScopes: ["issue:label"], - status: "planned", - }, - { - name: "tasker_assign_issue", - description: "Assign existing project members to an issue.", - requiredScopes: ["issue:assign"], - status: "planned", - }, + ...mcpRuntimeTools.map((tool) => ({ + name: tool.name, + description: tool.description, + requiredScopes: tool.requiredScopes, + status: "implemented" as const, + })), { name: "tasker_add_existing_project_member", description: "Add an existing workspace member to a granted project when explicitly allowed.", requiredScopes: ["project:member:add_existing"], status: "planned", }, - { - name: "tasker_append_comment", - description: "Append a comment to a granted issue.", - requiredScopes: ["issue:comment"], - status: "planned", - }, - { - name: "tasker_update_structured_blocks", - description: "Patch NODE.DC text/checker blocks in issue.detail_layout.", - requiredScopes: ["issue:structured_blocks:write"], - status: "planned", - }, ]; - diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts new file mode 100644 index 0000000..eca68df --- /dev/null +++ b/src/routes/mcp.ts @@ -0,0 +1,194 @@ +import { randomUUID } from "node:crypto"; + +import type { FastifyInstance, FastifyReply } from "fastify"; +import { ZodError, z } from "zod"; + +import { executeMcpTool, getToolsForSession } from "../mcp/tool-runtime.js"; +import type { AgentsRepository } from "../repositories/agents.js"; +import { ForbiddenError } from "../security/authorization.js"; +import { UnauthorizedError } from "../security/bearer.js"; +import type { TaskerClient } from "../tasker/client.js"; +import { authenticateAgent } from "./session.js"; + +const MCP_PROTOCOL_VERSION = "2025-06-18"; + +type McpRouteDeps = { + agentsRepository: AgentsRepository | null; + taskerClient: TaskerClient; +}; + +type JsonRpcId = string | number | null; + +type JsonRpcResponse = + | { + jsonrpc: "2.0"; + id: JsonRpcId; + result: unknown; + } + | { + jsonrpc: "2.0"; + id: JsonRpcId; + error: { + code: number; + message: string; + data?: unknown; + }; + }; + +const jsonRpcRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.union([z.string(), z.number(), z.null()]).optional(), + method: z.string().min(1), + params: z.unknown().optional(), +}); + +const toolsCallParamsSchema = z.object({ + name: z.string().min(1), + arguments: z.unknown().optional(), +}); + +export async function registerMcpRoutes(app: FastifyInstance, deps: McpRouteDeps): Promise { + app.get("/mcp", async (_request, reply) => + reply.status(405).send({ + ok: false, + error: "sse_not_supported", + message: "This MCP endpoint supports JSON-RPC over HTTP POST. Server-sent events are not enabled.", + }) + ); + + app.delete("/mcp", async (_request, reply) => reply.status(405).send({ ok: false, error: "sessions_not_stateful" })); + + app.post("/mcp", async (request, reply) => { + const parsed = jsonRpcRequestSchema.safeParse(request.body); + + if (!parsed.success) { + return sendJsonRpc(reply, makeError(null, -32600, "Invalid JSON-RPC request.", parsed.error.issues)); + } + + const message = parsed.data; + const id = message.id ?? null; + + if (message.id === undefined) { + return reply.status(202).send(); + } + + try { + switch (message.method) { + case "initialize": + reply.header("Mcp-Session-Id", randomUUID()); + return sendJsonRpc(reply, { + jsonrpc: "2.0", + id, + result: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { + tools: { + listChanged: false, + }, + }, + serverInfo: { + name: "nodedc-tasker-codex-api", + version: "0.1.0", + }, + }, + }); + case "ping": + return sendJsonRpc(reply, { jsonrpc: "2.0", id, result: {} }); + case "tools/list": { + const session = await authenticateAgent(request, deps); + return sendJsonRpc(reply, { + jsonrpc: "2.0", + id, + result: { + tools: getToolsForSession(session).map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + })), + }, + }); + } + case "tools/call": { + const session = await authenticateAgent(request, deps); + const params = toolsCallParamsSchema.parse(message.params); + const result = await executeMcpTool(session, params.name, params.arguments, deps); + return sendJsonRpc(reply, { + jsonrpc: "2.0", + id, + result, + }); + } + default: + return sendJsonRpc(reply, makeError(id, -32601, `Method not found: ${message.method}.`)); + } + } catch (error) { + return sendJsonRpc(reply, mapError(id, error)); + } + }); +} + +function sendJsonRpc(reply: FastifyReply, response: JsonRpcResponse): FastifyReply { + reply.header("Content-Type", "application/json"); + return reply.send(response); +} + +function mapError(id: JsonRpcId, error: unknown): JsonRpcResponse { + if (error instanceof ZodError) { + return makeError(id, -32602, "Invalid MCP tool arguments.", error.issues); + } + + if (error instanceof UnauthorizedError) { + return makeError(id, -32001, error.message); + } + + if (error instanceof ForbiddenError) { + return makeToolExecutionError(id, "forbidden", error.message); + } + + if (error instanceof Error && error.message.startsWith("Unknown MCP tool:")) { + return makeError(id, -32602, error.message); + } + + if (error instanceof Error) { + return makeToolExecutionError(id, error.name || "tool_execution_error", error.message); + } + + return makeToolExecutionError(id, "tool_execution_error", "MCP tool execution failed."); +} + +function makeError(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse { + return { + jsonrpc: "2.0", + id, + error: { + code, + message, + data, + }, + }; +} + +function makeToolExecutionError(id: JsonRpcId, code: string, message: string): JsonRpcResponse { + const payload = { + ok: false, + error: code, + message, + }; + + return { + jsonrpc: "2.0", + id, + result: { + content: [ + { + type: "text", + text: JSON.stringify(payload, null, 2), + }, + ], + structuredContent: payload, + isError: true, + }, + }; +} diff --git a/src/routes/tools.ts b/src/routes/tools.ts index 54cd86b..819d5d9 100644 --- a/src/routes/tools.ts +++ b/src/routes/tools.ts @@ -1,10 +1,7 @@ import type { FastifyInstance } from "fastify"; -import { z } from "zod"; -import type { AgentScope } from "../domain/scopes.js"; -import { structuredBlocksSchema } from "../domain/structured-blocks.js"; -import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js"; -import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js"; +import { executeMcpTool } from "../mcp/tool-runtime.js"; +import type { AgentsRepository } from "../repositories/agents.js"; import type { TaskerClient } from "../tasker/client.js"; import { authenticateAgent } from "./session.js"; @@ -13,188 +10,114 @@ type ToolRouteDeps = { taskerClient: TaskerClient; }; -const projectParamsSchema = z.object({ - projectId: z.string().min(1), -}); - -const issueParamsSchema = z.object({ - issueId: z.string().min(1), -}); - -const listIssuesQuerySchema = z.object({ - project_id: z.string().min(1), - workspace_slug: z.string().min(1).optional(), - query: z.string().min(1).optional(), -}); - -const projectContextQuerySchema = z.object({ - workspace_slug: z.string().min(1).optional(), -}); - -const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]); - -const createIssueBodySchema = z.object({ - project_id: z.string().min(1), - workspace_slug: z.string().min(1).nullish(), - title: z.string().min(1).max(500), - description: z.string().max(20000).optional(), - priority: prioritySchema.optional(), - structured_blocks: structuredBlocksSchema.optional(), -}); - -const updateIssueBodySchema = z.object({ - project_id: z.string().min(1), - workspace_slug: z.string().min(1).nullish(), - title: z.string().min(1).max(500).optional(), - description: z.string().max(20000).optional(), - priority: prioritySchema.optional(), - structured_blocks: structuredBlocksSchema.optional(), -}); - -const moveIssueBodySchema = z.object({ - project_id: z.string().min(1), - workspace_slug: z.string().min(1).nullish(), - state_id: z.string().min(1), -}); - -const commentBodySchema = z.object({ - project_id: z.string().min(1), - workspace_slug: z.string().min(1).nullish(), - body: z.string().min(1).max(20000), -}); - -const labelsBodySchema = z.object({ - project_id: z.string().min(1), - workspace_slug: z.string().min(1).nullish(), - label_ids: z.array(z.string().min(1)).default([]), -}); - -const assigneesBodySchema = z.object({ - project_id: z.string().min(1), - workspace_slug: z.string().min(1).nullish(), - member_ids: z.array(z.string().min(1)).default([]), -}); - export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise { app.get("/api/v1/tools/projects", async (request) => { const session = await authenticateAgent(request, deps); - requireScope(session, "project:read"); - - return deps.taskerClient.listGrantedProjects(session); + const result = await executeMcpTool(session, "tasker_list_projects", {}, deps); + return result.structuredContent; }); app.get("/api/v1/tools/projects/:projectId/context", async (request) => { const session = await authenticateAgent(request, deps); - const { projectId } = projectParamsSchema.parse(request.params); - const query = projectContextQuerySchema.parse(request.query); - requireScope(session, "project:read"); - requireProjectGrant(session, { projectId, workspaceSlug: query.workspace_slug }); - - return deps.taskerClient.getProjectContext(session, projectId, query.workspace_slug); + const params = request.params as { projectId: string }; + const query = request.query as { workspace_slug?: string }; + const result = await executeMcpTool( + session, + "tasker_get_project_context", + { + project_id: params.projectId, + workspace_slug: query.workspace_slug, + }, + deps + ); + return result.structuredContent; }); app.get("/api/v1/tools/issues", async (request) => { const session = await authenticateAgent(request, deps); - const query = listIssuesQuerySchema.parse(request.query); - requireScope(session, "issue:read"); - requireProjectGrant(session, { projectId: query.project_id, workspaceSlug: query.workspace_slug }); - - return deps.taskerClient.listIssues(session, query.project_id, query.workspace_slug, query.query); + const query = request.query as { project_id?: string; workspace_slug?: string; query?: string }; + const result = await executeMcpTool(session, "tasker_search_issues", query, deps); + return result.structuredContent; }); app.post("/api/v1/tools/issues", async (request) => { const session = await authenticateAgent(request, deps); - const body = createIssueBodySchema.parse(request.body); - requireToolAccess(session, "issue:create", body.project_id, body.workspace_slug); - - return deps.taskerClient.createIssue(session, { - project_id: body.project_id, - workspace_slug: body.workspace_slug, - title: body.title, - description: body.description, - priority: body.priority, - structured_blocks: body.structured_blocks, - }); + const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps); + return result.structuredContent; }); app.patch("/api/v1/tools/issues/:issueId", async (request) => { const session = await authenticateAgent(request, deps); - const { issueId } = issueParamsSchema.parse(request.params); - const body = updateIssueBodySchema.parse(request.body); - requireToolAccess(session, "issue:update", body.project_id, body.workspace_slug); - - if (body.structured_blocks) { - requireScope(session, "issue:structured_blocks:write"); - } - - return deps.taskerClient.updateIssue(session, issueId, { - project_id: body.project_id, - workspace_slug: body.workspace_slug, - title: body.title, - description: body.description, - priority: body.priority, - structured_blocks: body.structured_blocks, - }); + const params = request.params as { issueId: string }; + const result = await executeMcpTool( + session, + "tasker_update_issue", + { + ...(request.body as Record), + issue_id: params.issueId, + }, + deps + ); + return result.structuredContent; }); app.post("/api/v1/tools/issues/:issueId/move", async (request) => { const session = await authenticateAgent(request, deps); - const { issueId } = issueParamsSchema.parse(request.params); - const body = moveIssueBodySchema.parse(request.body); - requireToolAccess(session, "issue:move", body.project_id, body.workspace_slug); - - return deps.taskerClient.moveIssue(session, issueId, { - project_id: body.project_id, - workspace_slug: body.workspace_slug, - state_id: body.state_id, - }); + const params = request.params as { issueId: string }; + const result = await executeMcpTool( + session, + "tasker_move_issue", + { + ...(request.body as Record), + issue_id: params.issueId, + }, + deps + ); + return result.structuredContent; }); app.post("/api/v1/tools/issues/:issueId/comments", async (request) => { const session = await authenticateAgent(request, deps); - const { issueId } = issueParamsSchema.parse(request.params); - const body = commentBodySchema.parse(request.body); - requireToolAccess(session, "issue:comment", body.project_id, body.workspace_slug); - - return deps.taskerClient.appendComment(session, issueId, { - project_id: body.project_id, - workspace_slug: body.workspace_slug, - body: body.body, - }); + const params = request.params as { issueId: string }; + const result = await executeMcpTool( + session, + "tasker_append_comment", + { + ...(request.body as Record), + issue_id: params.issueId, + }, + deps + ); + return result.structuredContent; }); app.put("/api/v1/tools/issues/:issueId/labels", async (request) => { const session = await authenticateAgent(request, deps); - const { issueId } = issueParamsSchema.parse(request.params); - const body = labelsBodySchema.parse(request.body); - requireToolAccess(session, "issue:label", body.project_id, body.workspace_slug); - - return deps.taskerClient.setLabels(session, issueId, { - project_id: body.project_id, - workspace_slug: body.workspace_slug, - label_ids: body.label_ids, - }); + const params = request.params as { issueId: string }; + const result = await executeMcpTool( + session, + "tasker_set_issue_labels", + { + ...(request.body as Record), + issue_id: params.issueId, + }, + deps + ); + return result.structuredContent; }); app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => { const session = await authenticateAgent(request, deps); - const { issueId } = issueParamsSchema.parse(request.params); - const body = assigneesBodySchema.parse(request.body); - requireToolAccess(session, "issue:assign", body.project_id, body.workspace_slug); - - return deps.taskerClient.assignIssue(session, issueId, { - project_id: body.project_id, - workspace_slug: body.workspace_slug, - member_ids: body.member_ids, - }); + const params = request.params as { issueId: string }; + const result = await executeMcpTool( + session, + "tasker_assign_issue", + { + ...(request.body as Record), + issue_id: params.issueId, + }, + deps + ); + return result.structuredContent; }); } - -function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void { - requireScope(session, scope); - const grant = requireProjectGrant(session, { projectId, workspaceSlug }); - - if (!grant.scopes.includes(scope)) { - throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`); - } -} diff --git a/src/scripts/smoke-mcp-e2e.ts b/src/scripts/smoke-mcp-e2e.ts new file mode 100644 index 0000000..703ac9f --- /dev/null +++ b/src/scripts/smoke-mcp-e2e.ts @@ -0,0 +1,257 @@ +import { Pool } from "pg"; + +import { buildApp } from "../app.js"; +import { loadConfig } from "../config.js"; +import { runMigrations } from "../db/migrations.js"; + +const config = loadConfig({ + ...process.env, + LOG_LEVEL: process.env.LOG_LEVEL === "debug" ? "debug" : "silent", +}); +const workspaceSlug = readRequiredEnv("SMOKE_WORKSPACE_SLUG"); +const projectId = readRequiredEnv("SMOKE_PROJECT_ID"); + +if (!config.DATABASE_URL) { + throw new Error("DATABASE_URL is required for MCP e2e smoke test."); +} + +if (!config.NODEDC_INTERNAL_ACCESS_TOKEN) { + throw new Error("NODEDC_INTERNAL_ACCESS_TOKEN is required for MCP e2e smoke test."); +} + +const migrationPool = new Pool({ connectionString: config.DATABASE_URL }); +await runMigrations(migrationPool); +await migrationPool.end(); + +const app = await buildApp(config); + +try { + const suffix = Date.now().toString(36); + const agentId = await createAgent(suffix); + await upsertGrant(agentId); + const token = await createToken(agentId); + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-06-18", + }; + + await mcpRequest(1, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { + name: "nodedc-mcp-e2e-smoke", + version: "0.1.0", + }, + }); + + const toolsList = await mcpRequest(2, "tools/list", {}, headers); + const toolNames = toolsList.result.tools.map((tool: { name: string }) => tool.name); + assert(toolNames.includes("tasker_create_issue"), "create issue tool is listed with full grant"); + + const projects = await callTool(3, "tasker_list_projects", {}, headers); + const context = await callTool( + 4, + "tasker_get_project_context", + { + project_id: projectId, + workspace_slug: workspaceSlug, + }, + headers + ); + const createIssue = await callTool( + 5, + "tasker_create_issue", + { + project_id: projectId, + workspace_slug: workspaceSlug, + title: `NODE.DC MCP Codex smoke ${suffix}`, + description: "Created through MCP Streamable HTTP JSON-RPC.", + priority: "medium", + structured_blocks: [ + { + id: "mcp-current-architecture", + type: "text", + title: "Текущая архитектура", + body: "MCP client called Agent Gateway; Gateway routed into real Tasker internal adapter.", + }, + { + id: "mcp-checker", + type: "checker", + title: "Чекер MCP smoke", + items: [ + { + id: "mcp", + text: "MCP tools/call accepted", + checked: true, + }, + { + id: "tasker", + text: "Real Tasker issue created", + checked: true, + }, + ], + }, + ], + }, + headers + ); + + const issueId = createIssue.structuredContent.issue.id as string; + await callTool( + 6, + "tasker_append_comment", + { + issue_id: issueId, + project_id: projectId, + workspace_slug: workspaceSlug, + body: "MCP e2e smoke comment from Agent Gateway.", + }, + headers + ); + + const states = Array.isArray(context.structuredContent.states) ? context.structuredContent.states : []; + const targetState = states.find((state: any) => typeof state?.id === "string"); + if (targetState) { + await callTool( + 7, + "tasker_move_issue", + { + issue_id: issueId, + project_id: projectId, + workspace_slug: workspaceSlug, + state_id: targetState.id, + }, + headers + ); + } + + console.log( + JSON.stringify( + { + ok: true, + protocol: "mcp-streamable-http-json-rpc", + tasker_url: config.NODEDC_TASKER_INTERNAL_URL, + workspace_slug: workspaceSlug, + project_id: projectId, + visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null, + issue_id: issueId, + moved: Boolean(targetState), + }, + null, + 2 + ) + ); +} finally { + await app.close(); +} + +async function createAgent(suffix: string): Promise { + const response = await app.inject({ + method: "POST", + url: "/api/v1/agents", + payload: { + owner_user_id: `mcp-e2e-owner-${suffix}`, + owner_email: `mcp-e2e-${suffix}@example.test`, + display_name: `MCP E2E Codex ${suffix}`, + }, + }); + assertStatus(response.statusCode, 201, response.body); + return JSON.parse(response.body).agent.id; +} + +async function upsertGrant(agentId: string): Promise { + const response = await app.inject({ + method: "POST", + url: `/api/v1/agents/${agentId}/grants`, + payload: { + workspace_slug: workspaceSlug, + project_id: projectId, + scopes: [ + "workspace:read", + "project:read", + "issue:read", + "issue:create", + "issue:update", + "issue:move", + "issue:comment", + "issue:label", + "issue:assign", + "issue:structured_blocks:write", + ], + mode: "voluntary", + created_by_user_id: "mcp-e2e-smoke-admin", + }, + }); + assertStatus(response.statusCode, 201, response.body); +} + +async function createToken(agentId: string): Promise { + const response = await app.inject({ + method: "POST", + url: `/api/v1/agents/${agentId}/tokens`, + payload: { + name: "MCP e2e smoke token", + }, + }); + assertStatus(response.statusCode, 201, response.body); + return JSON.parse(response.body).token; +} + +async function callTool(id: number, name: string, toolArguments: unknown, headers: Record): Promise { + const payload = await mcpRequest( + id, + "tools/call", + { + name, + arguments: toolArguments, + }, + headers + ); + + if (payload.result?.isError) { + throw new Error(`MCP tool failed: ${JSON.stringify(payload.result.structuredContent)}`); + } + + return payload.result; +} + +async function mcpRequest(id: number, method: string, params?: unknown, headers?: Record): Promise { + const response = await app.inject({ + method: "POST", + url: "/mcp", + headers, + payload: { + jsonrpc: "2.0", + id, + method, + params, + }, + }); + assertStatus(response.statusCode, 200, response.body); + const payload = JSON.parse(response.body); + if (payload.error) { + throw new Error(`MCP error for ${method}: ${JSON.stringify(payload.error)}`); + } + return payload; +} + +function assertStatus(actual: number, expected: number, body: string): void { + if (actual !== expected) { + throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`); + } +} + +function assert(condition: unknown, message: string): void { + if (!condition) { + throw new Error(`MCP e2e smoke assertion failed: ${message}`); + } +} + +function readRequiredEnv(key: string): string { + const value = process.env[key]?.trim(); + if (!value) { + throw new Error(`${key} is required for MCP e2e smoke test.`); + } + return value; +} diff --git a/src/scripts/smoke-mcp.ts b/src/scripts/smoke-mcp.ts new file mode 100644 index 0000000..bdf5cf3 --- /dev/null +++ b/src/scripts/smoke-mcp.ts @@ -0,0 +1,218 @@ +import { Pool } from "pg"; + +import { buildApp } from "../app.js"; +import { loadConfig } from "../config.js"; +import { runMigrations } from "../db/migrations.js"; + +const envWithoutTaskerToken = { ...process.env }; +delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN; +envWithoutTaskerToken.LOG_LEVEL = "silent"; + +const config = loadConfig(envWithoutTaskerToken); + +if (!config.DATABASE_URL) { + throw new Error("DATABASE_URL is required for MCP smoke test."); +} + +const migrationPool = new Pool({ connectionString: config.DATABASE_URL }); +await runMigrations(migrationPool); +await migrationPool.end(); + +const app = await buildApp(config); + +try { + const suffix = Date.now().toString(36); + const projectId = `mcp-project-${suffix}`; + const workspaceSlug = `mcp-workspace-${suffix}`; + const agentId = await createAgent(suffix); + + await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read"]); + const token = await createToken(agentId); + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-06-18", + }; + + const initialize = await mcpRequest(1, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { + name: "nodedc-smoke", + version: "0.1.0", + }, + }); + assert(initialize.result?.capabilities?.tools, "initialize exposes tools capability"); + + const toolsList = await mcpRequest(2, "tools/list", {}, headers); + const toolNames = toolsList.result.tools.map((tool: { name: string }) => tool.name); + assert(toolNames.includes("tasker_get_agent_instructions"), "instructions tool is listed"); + assert(!toolNames.includes("tasker_create_issue"), "create tool is hidden without issue:create scope"); + + const instructions = await mcpRequest( + 3, + "tools/call", + { + name: "tasker_get_agent_instructions", + arguments: {}, + }, + headers + ); + assert(instructions.result?.structuredContent?.rules, "instructions tool returns structured rules"); + + const deniedCreate = await mcpRequest( + 4, + "tools/call", + { + name: "tasker_create_issue", + arguments: { + project_id: projectId, + workspace_slug: workspaceSlug, + title: "Should be denied by MCP smoke", + }, + }, + headers + ); + assert(deniedCreate.result?.isError === true, "create call returns MCP tool error without issue:create scope"); + + await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:update"]); + await upsertGrant(agentId, workspaceSlug, `${projectId}-structured`, [ + "workspace:read", + "project:read", + "issue:structured_blocks:write", + ]); + const deniedSplitGrantStructuredUpdate = await mcpRequest( + 5, + "tools/call", + { + name: "tasker_update_structured_blocks", + arguments: { + issue_id: "split-grant-issue", + project_id: projectId, + workspace_slug: workspaceSlug, + structured_blocks: [], + }, + }, + headers + ); + assert( + deniedSplitGrantStructuredUpdate.result?.structuredContent?.error === "forbidden", + "structured block writes require both scopes on the same project grant" + ); + + await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:create"]); + const allowedButNoAdapter = await mcpRequest( + 6, + "tools/call", + { + name: "tasker_create_issue", + arguments: { + project_id: projectId, + workspace_slug: workspaceSlug, + title: "Allowed by MCP, waiting for Tasker adapter token", + }, + }, + headers + ); + assert(allowedButNoAdapter.result?.isError === true, "allowed create reaches Tasker boundary"); + assert( + allowedButNoAdapter.result?.structuredContent?.error === "TaskerAdapterNotConfiguredError", + "Tasker boundary error is explicit" + ); + + console.log( + JSON.stringify( + { + ok: true, + agent_id: agentId, + token_prefix: token.split("_")[0], + tools_listed: toolNames.length, + checks: { + initialize: "passed", + tools_list: "passed", + instructions_call: "passed", + denied_without_scope: "passed", + denied_split_grant_structured_blocks: "passed", + allowed_request_reaches_tasker_boundary: "passed", + }, + }, + null, + 2 + ) + ); +} finally { + await app.close(); +} + +async function createAgent(suffix: string): Promise { + const response = await app.inject({ + method: "POST", + url: "/api/v1/agents", + payload: { + owner_user_id: `mcp-owner-${suffix}`, + owner_email: `mcp-${suffix}@example.test`, + display_name: `MCP Codex ${suffix}`, + }, + }); + assertStatus(response.statusCode, 201, response.body); + return JSON.parse(response.body).agent.id; +} + +async function upsertGrant(agentId: string, workspaceSlug: string, projectId: string, scopes: string[]): Promise { + const response = await app.inject({ + method: "POST", + url: `/api/v1/agents/${agentId}/grants`, + payload: { + workspace_slug: workspaceSlug, + project_id: projectId, + scopes, + mode: "voluntary", + created_by_user_id: "mcp-smoke-admin", + }, + }); + assertStatus(response.statusCode, 201, response.body); +} + +async function createToken(agentId: string): Promise { + const response = await app.inject({ + method: "POST", + url: `/api/v1/agents/${agentId}/tokens`, + payload: { + name: "MCP smoke token", + }, + }); + assertStatus(response.statusCode, 201, response.body); + return JSON.parse(response.body).token; +} + +async function mcpRequest(id: number, method: string, params?: unknown, headers?: Record): Promise { + const response = await app.inject({ + method: "POST", + url: "/mcp", + headers, + payload: { + jsonrpc: "2.0", + id, + method, + params, + }, + }); + assertStatus(response.statusCode, 200, response.body); + const payload = JSON.parse(response.body); + if (payload.error) { + throw new Error(`MCP error for ${method}: ${JSON.stringify(payload.error)}`); + } + return payload; +} + +function assertStatus(actual: number, expected: number, body: string): void { + if (actual !== expected) { + throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`); + } +} + +function assert(condition: unknown, message: string): void { + if (!condition) { + throw new Error(`MCP smoke assertion failed: ${message}`); + } +}