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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 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:
|
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.
|
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 over the same tool execution path.
|
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 against the real local Tasker runtime.
|
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 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.
|
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:
|
Example real localhost MCP e2e:
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ Acceptance:
|
||||||
- local Codex can move card state;
|
- local Codex can move card state;
|
||||||
- local Codex cannot delete/archive.
|
- 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
|
## Phase 6. Agent identity
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,15 @@ All write tools must accept:
|
||||||
idempotency_key
|
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
|
## Denied tools
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,9 @@ Risk: network retry creates duplicate cards/comments.
|
||||||
Mitigation:
|
Mitigation:
|
||||||
|
|
||||||
- required idempotency keys for write tools;
|
- 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
|
### 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 type { AppConfig } from "./config.js";
|
||||||
import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
|
import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
|
||||||
|
import { ToolExecutionInputError } from "./mcp/tool-runtime.js";
|
||||||
import { AgentsRepository } from "./repositories/agents.js";
|
import { AgentsRepository } from "./repositories/agents.js";
|
||||||
import { registerAgentRoutes } from "./routes/agents.js";
|
import { registerAgentRoutes } from "./routes/agents.js";
|
||||||
import { registerHealthRoutes } from "./routes/health.js";
|
import { registerHealthRoutes } from "./routes/health.js";
|
||||||
|
|
@ -66,6 +67,16 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
return;
|
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) {
|
if (error instanceof TaskerAdapterNotConfiguredError) {
|
||||||
void reply.status(503).send({
|
void reply.status(503).send({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { AgentScope } from "../domain/scopes.js";
|
import type { AgentScope } from "../domain/scopes.js";
|
||||||
import { structuredBlocksSchema } from "../domain/structured-blocks.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 { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
|
||||||
import type { TaskerClient } from "../tasker/client.js";
|
import type { TaskerClient } from "../tasker/client.js";
|
||||||
|
|
||||||
|
|
@ -27,9 +29,15 @@ export type McpToolResult = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExecuteToolDeps = {
|
type ExecuteToolDeps = {
|
||||||
|
agentsRepository?: AgentsRepository | null;
|
||||||
taskerClient: TaskerClient;
|
taskerClient: TaskerClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExecuteToolOptions = {
|
||||||
|
source?: "mcp" | "rest";
|
||||||
|
idempotencyKey?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const emptyInputSchema = {
|
const emptyInputSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -151,6 +159,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
||||||
structured_blocks: structuredBlocksJsonSchema,
|
structured_blocks: structuredBlocksJsonSchema,
|
||||||
|
idempotency_key: { type: "string" },
|
||||||
},
|
},
|
||||||
required: ["project_id", "title"],
|
required: ["project_id", "title"],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -172,6 +181,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
|
||||||
structured_blocks: structuredBlocksJsonSchema,
|
structured_blocks: structuredBlocksJsonSchema,
|
||||||
|
idempotency_key: { type: "string" },
|
||||||
},
|
},
|
||||||
required: ["issue_id", "project_id"],
|
required: ["issue_id", "project_id"],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -188,6 +198,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
properties: {
|
properties: {
|
||||||
...projectAndIssueInputSchema.properties,
|
...projectAndIssueInputSchema.properties,
|
||||||
structured_blocks: structuredBlocksJsonSchema,
|
structured_blocks: structuredBlocksJsonSchema,
|
||||||
|
idempotency_key: { type: "string" },
|
||||||
},
|
},
|
||||||
required: ["issue_id", "project_id", "structured_blocks"],
|
required: ["issue_id", "project_id", "structured_blocks"],
|
||||||
},
|
},
|
||||||
|
|
@ -203,6 +214,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
properties: {
|
properties: {
|
||||||
...projectAndIssueInputSchema.properties,
|
...projectAndIssueInputSchema.properties,
|
||||||
state_id: { type: "string" },
|
state_id: { type: "string" },
|
||||||
|
idempotency_key: { type: "string" },
|
||||||
},
|
},
|
||||||
required: ["issue_id", "project_id", "state_id"],
|
required: ["issue_id", "project_id", "state_id"],
|
||||||
},
|
},
|
||||||
|
|
@ -218,6 +230,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
properties: {
|
properties: {
|
||||||
...projectAndIssueInputSchema.properties,
|
...projectAndIssueInputSchema.properties,
|
||||||
body: { type: "string" },
|
body: { type: "string" },
|
||||||
|
idempotency_key: { type: "string" },
|
||||||
},
|
},
|
||||||
required: ["issue_id", "project_id", "body"],
|
required: ["issue_id", "project_id", "body"],
|
||||||
},
|
},
|
||||||
|
|
@ -233,6 +246,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
properties: {
|
properties: {
|
||||||
...projectAndIssueInputSchema.properties,
|
...projectAndIssueInputSchema.properties,
|
||||||
label_ids: { type: "array", items: { type: "string" } },
|
label_ids: { type: "array", items: { type: "string" } },
|
||||||
|
idempotency_key: { type: "string" },
|
||||||
},
|
},
|
||||||
required: ["issue_id", "project_id", "label_ids"],
|
required: ["issue_id", "project_id", "label_ids"],
|
||||||
},
|
},
|
||||||
|
|
@ -248,6 +262,7 @@ export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
|
||||||
properties: {
|
properties: {
|
||||||
...projectAndIssueInputSchema.properties,
|
...projectAndIssueInputSchema.properties,
|
||||||
member_ids: { type: "array", items: { type: "string" } },
|
member_ids: { type: "array", items: { type: "string" } },
|
||||||
|
idempotency_key: { type: "string" },
|
||||||
},
|
},
|
||||||
required: ["issue_id", "project_id", "member_ids"],
|
required: ["issue_id", "project_id", "member_ids"],
|
||||||
},
|
},
|
||||||
|
|
@ -271,6 +286,7 @@ const createIssueArgsSchema = z.object({
|
||||||
description: z.string().max(20000).optional(),
|
description: z.string().max(20000).optional(),
|
||||||
priority: prioritySchema.optional(),
|
priority: prioritySchema.optional(),
|
||||||
structured_blocks: structuredBlocksSchema.optional(),
|
structured_blocks: structuredBlocksSchema.optional(),
|
||||||
|
idempotency_key: z.string().optional(),
|
||||||
});
|
});
|
||||||
const issueArgsSchema = z.object({
|
const issueArgsSchema = z.object({
|
||||||
issue_id: z.string().min(1),
|
issue_id: z.string().min(1),
|
||||||
|
|
@ -307,9 +323,81 @@ export async function executeMcpTool(
|
||||||
session: AgentSessionRecord,
|
session: AgentSessionRecord,
|
||||||
name: string,
|
name: string,
|
||||||
rawArguments: unknown,
|
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
|
deps: ExecuteToolDeps
|
||||||
): Promise<McpToolResult> {
|
): Promise<McpToolResult> {
|
||||||
const args = rawArguments ?? {};
|
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "tasker_get_agent_instructions":
|
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> {
|
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
agent: {
|
agent: {
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,17 @@ type SessionRow = AgentRow & {
|
||||||
token_created_at: Date;
|
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 = {
|
export type CreateAgentInput = {
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
ownerEmail?: string | null;
|
ownerEmail?: string | null;
|
||||||
|
|
@ -113,6 +124,22 @@ export type CreateTokenInput = {
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IdempotencyClaim =
|
||||||
|
| {
|
||||||
|
status: "claimed";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "replay";
|
||||||
|
responseBody: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "in_progress";
|
||||||
|
lockedUntil: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "conflict";
|
||||||
|
};
|
||||||
|
|
||||||
export class AgentsRepository {
|
export class AgentsRepository {
|
||||||
constructor(private readonly pool: Pool) {}
|
constructor(private readonly pool: Pool) {}
|
||||||
|
|
||||||
|
|
@ -322,6 +349,101 @@ export class AgentsRepository {
|
||||||
[agentId, eventType, actorUserId ?? null, metadata]
|
[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 {
|
function assertAllowedScopes(scopes: AgentScope[]): void {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import { ZodError, z } from "zod";
|
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 type { AgentsRepository } from "../repositories/agents.js";
|
||||||
import { ForbiddenError } from "../security/authorization.js";
|
import { ForbiddenError } from "../security/authorization.js";
|
||||||
import { UnauthorizedError } from "../security/bearer.js";
|
import { UnauthorizedError } from "../security/bearer.js";
|
||||||
|
|
@ -113,7 +113,10 @@ export async function registerMcpRoutes(app: FastifyInstance, deps: McpRouteDeps
|
||||||
case "tools/call": {
|
case "tools/call": {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const params = toolsCallParamsSchema.parse(message.params);
|
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, {
|
return sendJsonRpc(reply, {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
|
|
@ -147,6 +150,10 @@ function mapError(id: JsonRpcId, error: unknown): JsonRpcResponse {
|
||||||
return makeToolExecutionError(id, "forbidden", error.message);
|
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:")) {
|
if (error instanceof Error && error.message.startsWith("Unknown MCP tool:")) {
|
||||||
return makeError(id, -32602, error.message);
|
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 = {
|
const payload = {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: code,
|
error: code,
|
||||||
message,
|
message,
|
||||||
|
details,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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> {
|
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
|
||||||
app.get("/api/v1/tools/projects", async (request) => {
|
app.get("/api/v1/tools/projects", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
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;
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -28,7 +28,8 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
||||||
project_id: params.projectId,
|
project_id: params.projectId,
|
||||||
workspace_slug: query.workspace_slug,
|
workspace_slug: query.workspace_slug,
|
||||||
},
|
},
|
||||||
deps
|
deps,
|
||||||
|
toolOptions(request)
|
||||||
);
|
);
|
||||||
return result.structuredContent;
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
@ -36,13 +37,13 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
||||||
app.get("/api/v1/tools/issues", async (request) => {
|
app.get("/api/v1/tools/issues", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
|
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;
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/v1/tools/issues", async (request) => {
|
app.post("/api/v1/tools/issues", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
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;
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -53,10 +54,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
||||||
session,
|
session,
|
||||||
"tasker_update_issue",
|
"tasker_update_issue",
|
||||||
{
|
{
|
||||||
...(request.body as Record<string, unknown>),
|
...requestBodyRecord(request.body),
|
||||||
issue_id: params.issueId,
|
issue_id: params.issueId,
|
||||||
},
|
},
|
||||||
deps
|
deps,
|
||||||
|
toolOptions(request)
|
||||||
);
|
);
|
||||||
return result.structuredContent;
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
@ -68,10 +70,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
||||||
session,
|
session,
|
||||||
"tasker_move_issue",
|
"tasker_move_issue",
|
||||||
{
|
{
|
||||||
...(request.body as Record<string, unknown>),
|
...requestBodyRecord(request.body),
|
||||||
issue_id: params.issueId,
|
issue_id: params.issueId,
|
||||||
},
|
},
|
||||||
deps
|
deps,
|
||||||
|
toolOptions(request)
|
||||||
);
|
);
|
||||||
return result.structuredContent;
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
@ -83,10 +86,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
||||||
session,
|
session,
|
||||||
"tasker_append_comment",
|
"tasker_append_comment",
|
||||||
{
|
{
|
||||||
...(request.body as Record<string, unknown>),
|
...requestBodyRecord(request.body),
|
||||||
issue_id: params.issueId,
|
issue_id: params.issueId,
|
||||||
},
|
},
|
||||||
deps
|
deps,
|
||||||
|
toolOptions(request)
|
||||||
);
|
);
|
||||||
return result.structuredContent;
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
@ -98,10 +102,11 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
||||||
session,
|
session,
|
||||||
"tasker_set_issue_labels",
|
"tasker_set_issue_labels",
|
||||||
{
|
{
|
||||||
...(request.body as Record<string, unknown>),
|
...requestBodyRecord(request.body),
|
||||||
issue_id: params.issueId,
|
issue_id: params.issueId,
|
||||||
},
|
},
|
||||||
deps
|
deps,
|
||||||
|
toolOptions(request)
|
||||||
);
|
);
|
||||||
return result.structuredContent;
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
@ -113,11 +118,35 @@ export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDe
|
||||||
session,
|
session,
|
||||||
"tasker_assign_issue",
|
"tasker_assign_issue",
|
||||||
{
|
{
|
||||||
...(request.body as Record<string, unknown>),
|
...requestBodyRecord(request.body),
|
||||||
issue_id: params.issueId,
|
issue_id: params.issueId,
|
||||||
},
|
},
|
||||||
deps
|
deps,
|
||||||
|
toolOptions(request)
|
||||||
);
|
);
|
||||||
return result.structuredContent;
|
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)}`,
|
`/api/v1/tools/projects/${projectId}/context?workspace_slug=${encodeURIComponent(workspaceSlug)}`,
|
||||||
authHeaders
|
authHeaders
|
||||||
);
|
);
|
||||||
const issue = await requestJson("POST", "/api/v1/tools/issues", authHeaders, {
|
const createIssuePayload = {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
title: `NODE.DC Codex API smoke ${suffix}`,
|
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;
|
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,
|
project_id: projectId,
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
body: "Smoke comment from Agent Gateway.",
|
body: "Smoke comment from Agent Gateway.",
|
||||||
|
|
@ -81,11 +86,16 @@ try {
|
||||||
const states = Array.isArray(context.states) ? context.states : [];
|
const states = Array.isArray(context.states) ? context.states : [];
|
||||||
const targetState = states.find((state) => typeof state?.id === "string");
|
const targetState = states.find((state) => typeof state?.id === "string");
|
||||||
if (targetState) {
|
if (targetState) {
|
||||||
await requestJson("POST", `/api/v1/tools/issues/${issueId}/move`, authHeaders, {
|
await requestJson(
|
||||||
project_id: projectId,
|
"POST",
|
||||||
workspace_slug: workspaceSlug,
|
`/api/v1/tools/issues/${issueId}/move`,
|
||||||
state_id: targetState.id,
|
{ ...authHeaders, "Idempotency-Key": `rest-move-${suffix}` },
|
||||||
});
|
{
|
||||||
|
project_id: projectId,
|
||||||
|
workspace_slug: workspaceSlug,
|
||||||
|
state_id: targetState.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -97,6 +107,7 @@ try {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
visible_projects: Array.isArray(projects.projects) ? projects.projects.length : null,
|
visible_projects: Array.isArray(projects.projects) ? projects.projects.length : null,
|
||||||
issue_id: issueId,
|
issue_id: issueId,
|
||||||
|
idempotent_replay: "passed",
|
||||||
moved: Boolean(targetState),
|
moved: Boolean(targetState),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|
@ -180,3 +191,9 @@ function readRequiredEnv(key: string): string {
|
||||||
}
|
}
|
||||||
return value;
|
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",
|
url: "/api/v1/tools/issues",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Idempotency-Key": `gateway-denied-create-${suffix}`,
|
||||||
},
|
},
|
||||||
payload: {
|
payload: {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
|
@ -85,6 +86,20 @@ try {
|
||||||
});
|
});
|
||||||
assertStatus(deniedCreateResponse.statusCode, 403, deniedCreateResponse.body);
|
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({
|
const writeGrantResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/agents/${agentId}/grants`,
|
url: `/api/v1/agents/${agentId}/grants`,
|
||||||
|
|
@ -103,6 +118,7 @@ try {
|
||||||
url: "/api/v1/tools/issues",
|
url: "/api/v1/tools/issues",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Idempotency-Key": `gateway-allowed-no-adapter-${suffix}`,
|
||||||
},
|
},
|
||||||
payload: {
|
payload: {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
|
@ -121,6 +137,7 @@ try {
|
||||||
checks: {
|
checks: {
|
||||||
session_auth: "passed",
|
session_auth: "passed",
|
||||||
denied_without_scope: "passed",
|
denied_without_scope: "passed",
|
||||||
|
idempotency_required: "passed",
|
||||||
allowed_request_reaches_tasker_boundary: "passed",
|
allowed_request_reaches_tasker_boundary: "passed",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -59,53 +59,54 @@ try {
|
||||||
},
|
},
|
||||||
headers
|
headers
|
||||||
);
|
);
|
||||||
const createIssue = await callTool(
|
const createIssueArguments = {
|
||||||
5,
|
project_id: projectId,
|
||||||
"tasker_create_issue",
|
workspace_slug: workspaceSlug,
|
||||||
{
|
title: `NODE.DC MCP Codex smoke ${suffix}`,
|
||||||
project_id: projectId,
|
description: "Created through MCP Streamable HTTP JSON-RPC.",
|
||||||
workspace_slug: workspaceSlug,
|
priority: "medium",
|
||||||
title: `NODE.DC MCP Codex smoke ${suffix}`,
|
idempotency_key: `mcp-create-${suffix}`,
|
||||||
description: "Created through MCP Streamable HTTP JSON-RPC.",
|
structured_blocks: [
|
||||||
priority: "medium",
|
{
|
||||||
structured_blocks: [
|
id: "mcp-current-architecture",
|
||||||
{
|
type: "text",
|
||||||
id: "mcp-current-architecture",
|
title: "Текущая архитектура",
|
||||||
type: "text",
|
body: "MCP client called Agent Gateway; Gateway routed into real Tasker internal adapter.",
|
||||||
title: "Текущая архитектура",
|
},
|
||||||
body: "MCP client called Agent Gateway; Gateway routed into real Tasker internal adapter.",
|
{
|
||||||
},
|
id: "mcp-checker",
|
||||||
{
|
type: "checker",
|
||||||
id: "mcp-checker",
|
title: "Чекер MCP smoke",
|
||||||
type: "checker",
|
items: [
|
||||||
title: "Чекер MCP smoke",
|
{
|
||||||
items: [
|
id: "mcp",
|
||||||
{
|
text: "MCP tools/call accepted",
|
||||||
id: "mcp",
|
checked: true,
|
||||||
text: "MCP tools/call accepted",
|
},
|
||||||
checked: true,
|
{
|
||||||
},
|
id: "tasker",
|
||||||
{
|
text: "Real Tasker issue created",
|
||||||
id: "tasker",
|
checked: true,
|
||||||
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);
|
||||||
headers
|
|
||||||
);
|
|
||||||
|
|
||||||
const issueId = createIssue.structuredContent.issue.id as string;
|
const issueId = createIssue.structuredContent.issue.id as string;
|
||||||
|
assert(replayedCreateIssue.structuredContent.issue.id === issueId, "idempotent MCP create returns the original issue");
|
||||||
|
|
||||||
await callTool(
|
await callTool(
|
||||||
6,
|
7,
|
||||||
"tasker_append_comment",
|
"tasker_append_comment",
|
||||||
{
|
{
|
||||||
issue_id: issueId,
|
issue_id: issueId,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
body: "MCP e2e smoke comment from Agent Gateway.",
|
body: "MCP e2e smoke comment from Agent Gateway.",
|
||||||
|
idempotency_key: `mcp-comment-${suffix}`,
|
||||||
},
|
},
|
||||||
headers
|
headers
|
||||||
);
|
);
|
||||||
|
|
@ -114,13 +115,14 @@ try {
|
||||||
const targetState = states.find((state: any) => typeof state?.id === "string");
|
const targetState = states.find((state: any) => typeof state?.id === "string");
|
||||||
if (targetState) {
|
if (targetState) {
|
||||||
await callTool(
|
await callTool(
|
||||||
7,
|
8,
|
||||||
"tasker_move_issue",
|
"tasker_move_issue",
|
||||||
{
|
{
|
||||||
issue_id: issueId,
|
issue_id: issueId,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
state_id: targetState.id,
|
state_id: targetState.id,
|
||||||
|
idempotency_key: `mcp-move-${suffix}`,
|
||||||
},
|
},
|
||||||
headers
|
headers
|
||||||
);
|
);
|
||||||
|
|
@ -136,6 +138,7 @@ try {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null,
|
visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null,
|
||||||
issue_id: issueId,
|
issue_id: issueId,
|
||||||
|
idempotent_replay: "passed",
|
||||||
moved: Boolean(targetState),
|
moved: Boolean(targetState),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,31 @@ try {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
title: "Should be denied by MCP smoke",
|
title: "Should be denied by MCP smoke",
|
||||||
|
idempotency_key: `mcp-denied-create-${suffix}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
headers
|
headers
|
||||||
);
|
);
|
||||||
assert(deniedCreate.result?.isError === true, "create call returns MCP tool error without issue:create scope");
|
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, ["workspace:read", "project:read", "issue:read", "issue:update"]);
|
||||||
await upsertGrant(agentId, workspaceSlug, `${projectId}-structured`, [
|
await upsertGrant(agentId, workspaceSlug, `${projectId}-structured`, [
|
||||||
"workspace:read",
|
"workspace:read",
|
||||||
|
|
@ -82,7 +101,7 @@ try {
|
||||||
"issue:structured_blocks:write",
|
"issue:structured_blocks:write",
|
||||||
]);
|
]);
|
||||||
const deniedSplitGrantStructuredUpdate = await mcpRequest(
|
const deniedSplitGrantStructuredUpdate = await mcpRequest(
|
||||||
5,
|
6,
|
||||||
"tools/call",
|
"tools/call",
|
||||||
{
|
{
|
||||||
name: "tasker_update_structured_blocks",
|
name: "tasker_update_structured_blocks",
|
||||||
|
|
@ -91,6 +110,7 @@ try {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
structured_blocks: [],
|
structured_blocks: [],
|
||||||
|
idempotency_key: `mcp-split-grant-${suffix}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
headers
|
headers
|
||||||
|
|
@ -102,7 +122,7 @@ try {
|
||||||
|
|
||||||
await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:create"]);
|
await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:create"]);
|
||||||
const allowedButNoAdapter = await mcpRequest(
|
const allowedButNoAdapter = await mcpRequest(
|
||||||
6,
|
7,
|
||||||
"tools/call",
|
"tools/call",
|
||||||
{
|
{
|
||||||
name: "tasker_create_issue",
|
name: "tasker_create_issue",
|
||||||
|
|
@ -110,6 +130,7 @@ try {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
workspace_slug: workspaceSlug,
|
workspace_slug: workspaceSlug,
|
||||||
title: "Allowed by MCP, waiting for Tasker adapter token",
|
title: "Allowed by MCP, waiting for Tasker adapter token",
|
||||||
|
idempotency_key: `mcp-allowed-no-adapter-${suffix}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
headers
|
headers
|
||||||
|
|
@ -132,6 +153,7 @@ try {
|
||||||
tools_list: "passed",
|
tools_list: "passed",
|
||||||
instructions_call: "passed",
|
instructions_call: "passed",
|
||||||
denied_without_scope: "passed",
|
denied_without_scope: "passed",
|
||||||
|
idempotency_required: "passed",
|
||||||
denied_split_grant_structured_blocks: "passed",
|
denied_split_grant_structured_blocks: "passed",
|
||||||
allowed_request_reaches_tasker_boundary: "passed",
|
allowed_request_reaches_tasker_boundary: "passed",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue