SEC - CODEX AGENTS: idempotent audited tool writes
This commit is contained in:
parent
c9519b52d2
commit
2c1e83dd37
10
README.md
10
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
11
src/app.ts
11
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<FastifyInstance> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<McpToolResult> {
|
||||
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<McpToolResult> {
|
||||
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<string, unknown>
|
||||
) {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
|
||||
return {
|
||||
agent: {
|
||||
|
|
|
|||
|
|
@ -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<IdempotencyClaim> {
|
||||
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<IdempotencyRow>(
|
||||
`
|
||||
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<IdempotencyRow>("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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ type ToolRouteDeps = {
|
|||
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
|
||||
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<string, unknown>),
|
||||
...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<string, unknown>),
|
||||
...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<string, unknown>),
|
||||
...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<string, unknown>),
|
||||
...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<string, unknown>),
|
||||
...requestBodyRecord(request.body),
|
||||
issue_id: params.issueId,
|
||||
},
|
||||
deps
|
||||
deps,
|
||||
toolOptions(request)
|
||||
);
|
||||
return result.structuredContent;
|
||||
});
|
||||
}
|
||||
|
||||
function toolOptions(request: { headers: Record<string, string | string[] | undefined> }): { source: "rest"; idempotencyKey: string | null } {
|
||||
return {
|
||||
source: "rest",
|
||||
idempotencyKey: readHeader(request.headers["idempotency-key"]),
|
||||
};
|
||||
}
|
||||
|
||||
function requestBodyRecord(body: unknown): Record<string, unknown> {
|
||||
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function readHeader(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null;
|
||||
}
|
||||
|
||||
return value ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue