diff --git a/README.md b/README.md index 07efed7..8a4f7a4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are - 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 JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints. +- Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes. +- Agent Gateway writes audit events for executed, replayed, and failed write-tool calls. - 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. @@ -90,10 +92,10 @@ No fake Tasker storage is embedded into Agent Gateway. Local verification is split into product layers: -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. +1. `npm run smoke:mcp` verifies MCP initialize, tool listing, bearer token auth, scope checks, grant checks, idempotency requirement, and the Tasker boundary. +2. `npm run smoke:gateway` verifies the REST compatibility boundary and idempotency requirement over the same tool execution path. +3. `npm run smoke:e2e` verifies REST tool endpoints and idempotent replay against the real local Tasker runtime. +4. `npm run smoke:mcp:e2e` verifies MCP tool calls and idempotent replay 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: diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 31f8031..a1e20c3 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -111,7 +111,7 @@ 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. +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, idempotency handling, audit events, and Tasker adapter calls. `npm run smoke:mcp:e2e` verifies real local Tasker writes and idempotent replay. ## Phase 6. Agent identity diff --git a/docs/MCP_TOOLS_CONTRACT.md b/docs/MCP_TOOLS_CONTRACT.md index e4a15f7..cc2947f 100644 --- a/docs/MCP_TOOLS_CONTRACT.md +++ b/docs/MCP_TOOLS_CONTRACT.md @@ -308,7 +308,15 @@ All write tools must accept: idempotency_key ``` -Agent Gateway stores result mapping and returns the prior result for duplicate keys. +Agent Gateway requires this value for all write tools. The key is scoped by agent, hashed together with the tool name and normalized arguments, and stored with a processing/completed state. + +Behavior: + +- first successful request stores the tool result for 24 hours; +- duplicate key with identical arguments returns the stored result and does not call Tasker again; +- duplicate key with different arguments returns `idempotency_key_conflict`; +- duplicate key while the first request is still processing returns `idempotency_key_in_progress`; +- failed writes release the key so the same operation can be retried. ## Denied tools diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 5246d09..cb3e5e9 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -92,7 +92,9 @@ Risk: network retry creates duplicate cards/comments. Mitigation: - required idempotency keys for write tools; -- store operation result by token and idempotency key. +- store operation result by agent and idempotency key; +- reject same key with different arguments; +- release failed writes so safe retries can run again. ### Reporting mode false confidence diff --git a/migrations/002_idempotency_processing.sql b/migrations/002_idempotency_processing.sql new file mode 100644 index 0000000..0cd9a60 --- /dev/null +++ b/migrations/002_idempotency_processing.sql @@ -0,0 +1,12 @@ +ALTER TABLE idempotency_keys + ALTER COLUMN response_body DROP NOT NULL; + +ALTER TABLE idempotency_keys + ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'completed' + CHECK (status IN ('processing', 'completed')); + +ALTER TABLE idempotency_keys + ADD COLUMN IF NOT EXISTS locked_until timestamptz; + +CREATE INDEX IF NOT EXISTS idempotency_keys_status_idx ON idempotency_keys(status); +CREATE INDEX IF NOT EXISTS idempotency_keys_locked_until_idx ON idempotency_keys(locked_until); diff --git a/src/app.ts b/src/app.ts index 9b8719b..7ae9fa4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,6 +3,7 @@ import { ZodError } from "zod"; import type { AppConfig } from "./config.js"; import { createPool, DatabaseNotConfiguredError } from "./db/pool.js"; +import { ToolExecutionInputError } from "./mcp/tool-runtime.js"; import { AgentsRepository } from "./repositories/agents.js"; import { registerAgentRoutes } from "./routes/agents.js"; import { registerHealthRoutes } from "./routes/health.js"; @@ -66,6 +67,16 @@ export async function buildApp(config: AppConfig): Promise { return; } + if (error instanceof ToolExecutionInputError) { + void reply.status(error.httpStatus).send({ + ok: false, + error: error.code, + message: error.message, + details: error.details, + }); + return; + } + if (error instanceof TaskerAdapterNotConfiguredError) { void reply.status(503).send({ ok: false, diff --git a/src/mcp/tool-runtime.ts b/src/mcp/tool-runtime.ts index f449a01..a3e1034 100644 --- a/src/mcp/tool-runtime.ts +++ b/src/mcp/tool-runtime.ts @@ -1,8 +1,10 @@ +import { createHash } from "node:crypto"; + 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 type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js"; import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js"; import type { TaskerClient } from "../tasker/client.js"; @@ -27,9 +29,15 @@ export type McpToolResult = { }; type ExecuteToolDeps = { + agentsRepository?: AgentsRepository | null; taskerClient: TaskerClient; }; +type ExecuteToolOptions = { + source?: "mcp" | "rest"; + idempotencyKey?: string | null; +}; + const emptyInputSchema = { type: "object", additionalProperties: false, @@ -151,6 +159,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ description: { type: "string" }, priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] }, structured_blocks: structuredBlocksJsonSchema, + idempotency_key: { type: "string" }, }, required: ["project_id", "title"], additionalProperties: false, @@ -172,6 +181,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ description: { type: "string" }, priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] }, structured_blocks: structuredBlocksJsonSchema, + idempotency_key: { type: "string" }, }, required: ["issue_id", "project_id"], additionalProperties: false, @@ -188,6 +198,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ properties: { ...projectAndIssueInputSchema.properties, structured_blocks: structuredBlocksJsonSchema, + idempotency_key: { type: "string" }, }, required: ["issue_id", "project_id", "structured_blocks"], }, @@ -203,6 +214,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ properties: { ...projectAndIssueInputSchema.properties, state_id: { type: "string" }, + idempotency_key: { type: "string" }, }, required: ["issue_id", "project_id", "state_id"], }, @@ -218,6 +230,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ properties: { ...projectAndIssueInputSchema.properties, body: { type: "string" }, + idempotency_key: { type: "string" }, }, required: ["issue_id", "project_id", "body"], }, @@ -233,6 +246,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ properties: { ...projectAndIssueInputSchema.properties, label_ids: { type: "array", items: { type: "string" } }, + idempotency_key: { type: "string" }, }, required: ["issue_id", "project_id", "label_ids"], }, @@ -248,6 +262,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [ properties: { ...projectAndIssueInputSchema.properties, member_ids: { type: "array", items: { type: "string" } }, + idempotency_key: { type: "string" }, }, required: ["issue_id", "project_id", "member_ids"], }, @@ -271,6 +286,7 @@ const createIssueArgsSchema = z.object({ description: z.string().max(20000).optional(), priority: prioritySchema.optional(), structured_blocks: structuredBlocksSchema.optional(), + idempotency_key: z.string().optional(), }); const issueArgsSchema = z.object({ issue_id: z.string().min(1), @@ -307,9 +323,81 @@ export async function executeMcpTool( session: AgentSessionRecord, name: string, rawArguments: unknown, + deps: ExecuteToolDeps, + options: ExecuteToolOptions = {} +): Promise { + const tool = mcpRuntimeTools.find((candidate) => candidate.name === name); + + if (!tool) { + throw new Error(`Unknown MCP tool: ${name}`); + } + + const { args, idempotencyKey } = prepareToolArguments(rawArguments, options.idempotencyKey); + const isWriteTool = tool.annotations?.readOnlyHint !== true; + + if (!isWriteTool) { + return executeMcpToolOnce(session, name, args, deps); + } + + if (!deps.agentsRepository) { + throw new ToolExecutionInputError("idempotency_unavailable", "Agent Gateway persistence is required for write tools.", 503); + } + + if (!idempotencyKey) { + throw new ToolExecutionInputError("idempotency_key_required", "Write tools require an idempotency key.", 400); + } + + const requestHash = hashToolRequest(name, args); + const claim = await deps.agentsRepository.claimIdempotencyKey(session.agent.id, idempotencyKey, requestHash); + + if (claim.status === "replay") { + await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.replayed", session.agent.ownerUserId, { + source: options.source ?? "mcp", + toolName: name, + idempotencyKey, + }); + return claim.responseBody as McpToolResult; + } + + if (claim.status === "conflict") { + throw new ToolExecutionInputError("idempotency_key_conflict", "Idempotency key was already used with different arguments.", 409); + } + + if (claim.status === "in_progress") { + throw new ToolExecutionInputError("idempotency_key_in_progress", "Idempotency key is currently processing.", 409, { + lockedUntil: claim.lockedUntil, + }); + } + + try { + const result = await executeMcpToolOnce(session, name, args, deps); + await deps.agentsRepository.completeIdempotencyKey(session.agent.id, idempotencyKey, result); + await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.executed", session.agent.ownerUserId, { + source: options.source ?? "mcp", + toolName: name, + idempotencyKey, + arguments: summarizeToolArguments(args), + }); + return result; + } catch (error) { + await deps.agentsRepository.releaseIdempotencyKey(session.agent.id, idempotencyKey); + await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.failed", session.agent.ownerUserId, { + source: options.source ?? "mcp", + toolName: name, + idempotencyKey, + error: error instanceof Error ? error.name : "unknown_error", + message: error instanceof Error ? error.message : "Unknown tool execution error.", + }); + throw error; + } +} + +async function executeMcpToolOnce( + session: AgentSessionRecord, + name: string, + args: unknown, deps: ExecuteToolDeps ): Promise { - const args = rawArguments ?? {}; switch (name) { case "tasker_get_agent_instructions": @@ -421,6 +509,85 @@ function asToolResult(payload: unknown): McpToolResult { }; } +export class ToolExecutionInputError extends Error { + constructor( + readonly code: string, + message: string, + readonly httpStatus: number, + readonly details?: Record + ) { + super(message); + this.name = "ToolExecutionInputError"; + } +} + +function prepareToolArguments(rawArguments: unknown, headerIdempotencyKey?: string | null): { args: unknown; idempotencyKey: string | null } { + if (!isPlainRecord(rawArguments)) { + return { + args: rawArguments ?? {}, + idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey), + }; + } + + const { idempotency_key: bodyIdempotencyKey, ...args } = rawArguments; + + return { + args, + idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey ?? (typeof bodyIdempotencyKey === "string" ? bodyIdempotencyKey : null)), + }; +} + +function normalizeIdempotencyKey(value?: string | null): string | null { + const normalized = value?.trim(); + + if (!normalized) { + return null; + } + + if (normalized.length > 200) { + throw new ToolExecutionInputError("idempotency_key_invalid", "Idempotency key must be 200 characters or fewer.", 400); + } + + return normalized; +} + +function hashToolRequest(name: string, args: unknown): string { + return createHash("sha256").update(stableStringify({ name, args })).digest("hex"); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + + if (isPlainRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + +function summarizeToolArguments(args: unknown): Record { + if (!isPlainRecord(args)) { + return {}; + } + + return { + workspace_slug: args.workspace_slug, + project_id: args.project_id, + issue_id: args.issue_id, + state_id: args.state_id, + has_structured_blocks: Array.isArray(args.structured_blocks), + }; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function buildAgentInstructions(session: AgentSessionRecord): Record { return { agent: { diff --git a/src/repositories/agents.ts b/src/repositories/agents.ts index da73944..48ac843 100644 --- a/src/repositories/agents.ts +++ b/src/repositories/agents.ts @@ -92,6 +92,17 @@ type SessionRow = AgentRow & { token_created_at: Date; }; +type IdempotencyRow = { + key: string; + agent_id: string; + request_hash: string; + response_body: unknown | null; + status: "processing" | "completed"; + created_at: Date; + expires_at: Date; + locked_until: Date | null; +}; + export type CreateAgentInput = { ownerUserId: string; ownerEmail?: string | null; @@ -113,6 +124,22 @@ export type CreateTokenInput = { expiresAt?: string | null; }; +export type IdempotencyClaim = + | { + status: "claimed"; + } + | { + status: "replay"; + responseBody: unknown; + } + | { + status: "in_progress"; + lockedUntil: string | null; + } + | { + status: "conflict"; + }; + export class AgentsRepository { constructor(private readonly pool: Pool) {} @@ -322,6 +349,101 @@ export class AgentsRepository { [agentId, eventType, actorUserId ?? null, metadata] ); } + + async claimIdempotencyKey(agentId: string, idempotencyKey: string, requestHash: string): Promise { + const storageKey = buildIdempotencyStorageKey(agentId, idempotencyKey); + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); + await client.query("DELETE FROM idempotency_keys WHERE key = $1 AND expires_at <= now()", [storageKey]); + + const inserted = await client.query( + ` + INSERT INTO idempotency_keys(key, agent_id, request_hash, response_body, status, expires_at, locked_until) + VALUES ($1, $2, $3, NULL, 'processing', now() + interval '24 hours', now() + interval '5 minutes') + ON CONFLICT (key) DO NOTHING + RETURNING * + `, + [storageKey, agentId, requestHash] + ); + + if (inserted.rows[0]) { + await client.query("COMMIT"); + return { status: "claimed" }; + } + + const existing = await client.query("SELECT * FROM idempotency_keys WHERE key = $1 AND agent_id = $2 FOR UPDATE", [ + storageKey, + agentId, + ]); + const row = existing.rows[0]; + + if (!row) { + await client.query("COMMIT"); + return { status: "conflict" }; + } + + if (row.request_hash !== requestHash) { + await client.query("COMMIT"); + return { status: "conflict" }; + } + + if (row.status === "completed") { + await client.query("COMMIT"); + return { + status: "replay", + responseBody: row.response_body, + }; + } + + if (row.locked_until && row.locked_until > new Date()) { + await client.query("COMMIT"); + return { + status: "in_progress", + lockedUntil: row.locked_until.toISOString(), + }; + } + + await client.query( + ` + UPDATE idempotency_keys + SET locked_until = now() + interval '5 minutes' + WHERE key = $1 AND agent_id = $2 + `, + [storageKey, agentId] + ); + await client.query("COMMIT"); + return { status: "claimed" }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async completeIdempotencyKey(agentId: string, idempotencyKey: string, responseBody: unknown): Promise { + await this.pool.query( + ` + UPDATE idempotency_keys + SET response_body = $3, status = 'completed', locked_until = NULL + WHERE key = $1 AND agent_id = $2 + `, + [buildIdempotencyStorageKey(agentId, idempotencyKey), agentId, responseBody] + ); + } + + async releaseIdempotencyKey(agentId: string, idempotencyKey: string): Promise { + await this.pool.query("DELETE FROM idempotency_keys WHERE key = $1 AND agent_id = $2 AND status = 'processing'", [ + buildIdempotencyStorageKey(agentId, idempotencyKey), + agentId, + ]); + } +} + +function buildIdempotencyStorageKey(agentId: string, idempotencyKey: string): string { + return `${agentId}:${idempotencyKey}`; } function assertAllowedScopes(scopes: AgentScope[]): void { diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index eca68df..4611a23 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -3,7 +3,7 @@ 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 { ToolExecutionInputError, 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"; @@ -113,7 +113,10 @@ export async function registerMcpRoutes(app: FastifyInstance, deps: McpRouteDeps 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); + const result = await executeMcpTool(session, params.name, params.arguments, deps, { + source: "mcp", + idempotencyKey: readHeader(request.headers["idempotency-key"]), + }); return sendJsonRpc(reply, { jsonrpc: "2.0", id, @@ -147,6 +150,10 @@ function mapError(id: JsonRpcId, error: unknown): JsonRpcResponse { return makeToolExecutionError(id, "forbidden", error.message); } + if (error instanceof ToolExecutionInputError) { + return makeToolExecutionError(id, error.code, error.message, error.details); + } + if (error instanceof Error && error.message.startsWith("Unknown MCP tool:")) { return makeError(id, -32602, error.message); } @@ -170,11 +177,12 @@ function makeError(id: JsonRpcId, code: number, message: string, data?: unknown) }; } -function makeToolExecutionError(id: JsonRpcId, code: string, message: string): JsonRpcResponse { +function makeToolExecutionError(id: JsonRpcId, code: string, message: string, details?: Record): JsonRpcResponse { const payload = { ok: false, error: code, message, + details, }; return { @@ -192,3 +200,11 @@ function makeToolExecutionError(id: JsonRpcId, code: string, message: string): J }, }; } + +function readHeader(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} diff --git a/src/routes/tools.ts b/src/routes/tools.ts index 819d5d9..3db32c3 100644 --- a/src/routes/tools.ts +++ b/src/routes/tools.ts @@ -13,7 +13,7 @@ type ToolRouteDeps = { export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise { app.get("/api/v1/tools/projects", async (request) => { const session = await authenticateAgent(request, deps); - const result = await executeMcpTool(session, "tasker_list_projects", {}, deps); + const result = await executeMcpTool(session, "tasker_list_projects", {}, deps, toolOptions(request)); return result.structuredContent; }); @@ -28,7 +28,8 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe project_id: params.projectId, workspace_slug: query.workspace_slug, }, - deps + deps, + toolOptions(request) ); return result.structuredContent; }); @@ -36,13 +37,13 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe app.get("/api/v1/tools/issues", async (request) => { const session = await authenticateAgent(request, deps); const query = request.query as { project_id?: string; workspace_slug?: string; query?: string }; - const result = await executeMcpTool(session, "tasker_search_issues", query, deps); + const result = await executeMcpTool(session, "tasker_search_issues", query, deps, toolOptions(request)); return result.structuredContent; }); app.post("/api/v1/tools/issues", async (request) => { const session = await authenticateAgent(request, deps); - const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps); + const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps, toolOptions(request)); return result.structuredContent; }); @@ -53,10 +54,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe session, "tasker_update_issue", { - ...(request.body as Record), + ...requestBodyRecord(request.body), issue_id: params.issueId, }, - deps + deps, + toolOptions(request) ); return result.structuredContent; }); @@ -68,10 +70,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe session, "tasker_move_issue", { - ...(request.body as Record), + ...requestBodyRecord(request.body), issue_id: params.issueId, }, - deps + deps, + toolOptions(request) ); return result.structuredContent; }); @@ -83,10 +86,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe session, "tasker_append_comment", { - ...(request.body as Record), + ...requestBodyRecord(request.body), issue_id: params.issueId, }, - deps + deps, + toolOptions(request) ); return result.structuredContent; }); @@ -98,10 +102,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe session, "tasker_set_issue_labels", { - ...(request.body as Record), + ...requestBodyRecord(request.body), issue_id: params.issueId, }, - deps + deps, + toolOptions(request) ); return result.structuredContent; }); @@ -113,11 +118,35 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe session, "tasker_assign_issue", { - ...(request.body as Record), + ...requestBodyRecord(request.body), issue_id: params.issueId, }, - deps + deps, + toolOptions(request) ); return result.structuredContent; }); } + +function toolOptions(request: { headers: Record }): { source: "rest"; idempotencyKey: string | null } { + return { + source: "rest", + idempotencyKey: readHeader(request.headers["idempotency-key"]), + }; +} + +function requestBodyRecord(body: unknown): Record { + if (typeof body === "object" && body !== null && !Array.isArray(body)) { + return body as Record; + } + + return {}; +} + +function readHeader(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} diff --git a/src/scripts/smoke-e2e.ts b/src/scripts/smoke-e2e.ts index 92573b8..3df1505 100644 --- a/src/scripts/smoke-e2e.ts +++ b/src/scripts/smoke-e2e.ts @@ -38,7 +38,7 @@ try { `/api/v1/tools/projects/${projectId}/context?workspace_slug=${encodeURIComponent(workspaceSlug)}`, authHeaders ); - const issue = await requestJson("POST", "/api/v1/tools/issues", authHeaders, { + const createIssuePayload = { project_id: projectId, workspace_slug: workspaceSlug, title: `NODE.DC Codex API smoke ${suffix}`, @@ -69,10 +69,15 @@ try { ], }, ], - }); + }; + const createHeaders = { ...authHeaders, "Idempotency-Key": `rest-create-${suffix}` }; + const issue = await requestJson("POST", "/api/v1/tools/issues", createHeaders, createIssuePayload); + const replayedIssue = await requestJson("POST", "/api/v1/tools/issues", createHeaders, createIssuePayload); const issueId = issue.issue.id as string; - await requestJson("POST", `/api/v1/tools/issues/${issueId}/comments`, authHeaders, { + assert(replayedIssue.issue.id === issueId, "idempotent REST create returns the original issue"); + + await requestJson("POST", `/api/v1/tools/issues/${issueId}/comments`, { ...authHeaders, "Idempotency-Key": `rest-comment-${suffix}` }, { project_id: projectId, workspace_slug: workspaceSlug, body: "Smoke comment from Agent Gateway.", @@ -81,11 +86,16 @@ try { const states = Array.isArray(context.states) ? context.states : []; const targetState = states.find((state) => typeof state?.id === "string"); if (targetState) { - await requestJson("POST", `/api/v1/tools/issues/${issueId}/move`, authHeaders, { - project_id: projectId, - workspace_slug: workspaceSlug, - state_id: targetState.id, - }); + await requestJson( + "POST", + `/api/v1/tools/issues/${issueId}/move`, + { ...authHeaders, "Idempotency-Key": `rest-move-${suffix}` }, + { + project_id: projectId, + workspace_slug: workspaceSlug, + state_id: targetState.id, + } + ); } console.log( @@ -97,6 +107,7 @@ try { project_id: projectId, visible_projects: Array.isArray(projects.projects) ? projects.projects.length : null, issue_id: issueId, + idempotent_replay: "passed", moved: Boolean(targetState), }, null, @@ -180,3 +191,9 @@ function readRequiredEnv(key: string): string { } return value; } + +function assert(condition: unknown, message: string): void { + if (!condition) { + throw new Error(`E2E smoke assertion failed: ${message}`); + } +} diff --git a/src/scripts/smoke-gateway.ts b/src/scripts/smoke-gateway.ts index 49e020b..9d5817c 100644 --- a/src/scripts/smoke-gateway.ts +++ b/src/scripts/smoke-gateway.ts @@ -76,6 +76,7 @@ try { url: "/api/v1/tools/issues", headers: { Authorization: `Bearer ${token}`, + "Idempotency-Key": `gateway-denied-create-${suffix}`, }, payload: { project_id: projectId, @@ -85,6 +86,20 @@ try { }); assertStatus(deniedCreateResponse.statusCode, 403, deniedCreateResponse.body); + const missingIdempotencyResponse = await app.inject({ + method: "POST", + url: "/api/v1/tools/issues", + headers: { + Authorization: `Bearer ${token}`, + }, + payload: { + project_id: projectId, + workspace_slug: workspaceSlug, + title: "Should be denied before write because idempotency key is missing", + }, + }); + assertStatus(missingIdempotencyResponse.statusCode, 400, missingIdempotencyResponse.body); + const writeGrantResponse = await app.inject({ method: "POST", url: `/api/v1/agents/${agentId}/grants`, @@ -103,6 +118,7 @@ try { url: "/api/v1/tools/issues", headers: { Authorization: `Bearer ${token}`, + "Idempotency-Key": `gateway-allowed-no-adapter-${suffix}`, }, payload: { project_id: projectId, @@ -121,6 +137,7 @@ try { checks: { session_auth: "passed", denied_without_scope: "passed", + idempotency_required: "passed", allowed_request_reaches_tasker_boundary: "passed", }, }, diff --git a/src/scripts/smoke-mcp-e2e.ts b/src/scripts/smoke-mcp-e2e.ts index 703ac9f..a0ddfb4 100644 --- a/src/scripts/smoke-mcp-e2e.ts +++ b/src/scripts/smoke-mcp-e2e.ts @@ -59,53 +59,54 @@ try { }, 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 createIssueArguments = { + project_id: projectId, + workspace_slug: workspaceSlug, + title: `NODE.DC MCP Codex smoke ${suffix}`, + description: "Created through MCP Streamable HTTP JSON-RPC.", + priority: "medium", + idempotency_key: `mcp-create-${suffix}`, + 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, + }, + ], + }, + ], + }; + const createIssue = await callTool(5, "tasker_create_issue", createIssueArguments, headers); + const replayedCreateIssue = await callTool(6, "tasker_create_issue", createIssueArguments, headers); const issueId = createIssue.structuredContent.issue.id as string; + assert(replayedCreateIssue.structuredContent.issue.id === issueId, "idempotent MCP create returns the original issue"); + await callTool( - 6, + 7, "tasker_append_comment", { issue_id: issueId, project_id: projectId, workspace_slug: workspaceSlug, body: "MCP e2e smoke comment from Agent Gateway.", + idempotency_key: `mcp-comment-${suffix}`, }, headers ); @@ -114,13 +115,14 @@ try { const targetState = states.find((state: any) => typeof state?.id === "string"); if (targetState) { await callTool( - 7, + 8, "tasker_move_issue", { issue_id: issueId, project_id: projectId, workspace_slug: workspaceSlug, state_id: targetState.id, + idempotency_key: `mcp-move-${suffix}`, }, headers ); @@ -136,6 +138,7 @@ try { project_id: projectId, visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null, issue_id: issueId, + idempotent_replay: "passed", moved: Boolean(targetState), }, null, diff --git a/src/scripts/smoke-mcp.ts b/src/scripts/smoke-mcp.ts index bdf5cf3..bb5efee 100644 --- a/src/scripts/smoke-mcp.ts +++ b/src/scripts/smoke-mcp.ts @@ -69,12 +69,31 @@ try { project_id: projectId, workspace_slug: workspaceSlug, title: "Should be denied by MCP smoke", + idempotency_key: `mcp-denied-create-${suffix}`, }, }, headers ); assert(deniedCreate.result?.isError === true, "create call returns MCP tool error without issue:create scope"); + const missingIdempotencyKey = await mcpRequest( + 5, + "tools/call", + { + name: "tasker_create_issue", + arguments: { + project_id: projectId, + workspace_slug: workspaceSlug, + title: "Should be denied before write because idempotency key is missing", + }, + }, + headers + ); + assert( + missingIdempotencyKey.result?.structuredContent?.error === "idempotency_key_required", + "write tools require idempotency key" + ); + await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:update"]); await upsertGrant(agentId, workspaceSlug, `${projectId}-structured`, [ "workspace:read", @@ -82,7 +101,7 @@ try { "issue:structured_blocks:write", ]); const deniedSplitGrantStructuredUpdate = await mcpRequest( - 5, + 6, "tools/call", { name: "tasker_update_structured_blocks", @@ -91,6 +110,7 @@ try { project_id: projectId, workspace_slug: workspaceSlug, structured_blocks: [], + idempotency_key: `mcp-split-grant-${suffix}`, }, }, headers @@ -102,7 +122,7 @@ try { await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:create"]); const allowedButNoAdapter = await mcpRequest( - 6, + 7, "tools/call", { name: "tasker_create_issue", @@ -110,6 +130,7 @@ try { project_id: projectId, workspace_slug: workspaceSlug, title: "Allowed by MCP, waiting for Tasker adapter token", + idempotency_key: `mcp-allowed-no-adapter-${suffix}`, }, }, headers @@ -132,6 +153,7 @@ try { tools_list: "passed", instructions_call: "passed", denied_without_scope: "passed", + idempotency_required: "passed", denied_split_grant_structured_blocks: "passed", allowed_request_reaches_tasker_boundary: "passed", },