API - CODEX AGENTS: MCP transport and real e2e smoke

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 20:01:14 +03:00
parent 418914fefd
commit c9519b52d2
12 changed files with 1306 additions and 227 deletions

View File

@ -27,7 +27,9 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are
- Opaque agent tokens are generated once and stored only as SHA-256 hashes.
- Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls.
- Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter.
- MCP and Tasker write execution are documented but not implemented yet.
- MCP JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints.
- Tool execution calls the real Tasker internal adapter; no fake Tasker storage exists in Gateway.
- Local real e2e smoke verifies Gateway -> MCP -> Tasker runtime writes.
## Local development
@ -44,6 +46,7 @@ Useful checks:
```bash
npm run check
npm run build
npm run smoke:mcp
npm run smoke:gateway
curl http://127.0.0.1:4100/readyz
curl http://127.0.0.1:4100/api/v1/meta/capabilities
@ -70,15 +73,52 @@ curl http://127.0.0.1:4100/api/v1/agent-session \
Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added.
Call MCP tools through the JSON-RPC endpoint:
```bash
curl -sS http://127.0.0.1:4100/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-06-18' \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{}}' | jq
```
## Local testing strategy
No fake Tasker storage is embedded into Agent Gateway.
Local verification is split into product layers:
1. `npm run smoke:gateway` verifies real Agent Gateway persistence, bearer token auth, scope checks, grant checks, and the boundary before Tasker calls.
2. Full localhost e2e starts after Tasker implements `/api/internal/nodedc/agent/...` adapter. Then the same Gateway tool endpoints call the real local Tasker runtime.
3. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced.
1. `npm run smoke:mcp` verifies MCP initialize, tool listing, bearer token auth, scope checks, grant checks, and the Tasker boundary.
2. `npm run smoke:gateway` verifies the REST compatibility boundary over the same tool execution path.
3. `npm run smoke:e2e` verifies REST tool endpoints against the real local Tasker runtime.
4. `npm run smoke:mcp:e2e` verifies MCP tool calls against the real local Tasker runtime.
5. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced.
Example real localhost MCP e2e:
```bash
TOKEN=$(python3 - <<'PY'
from pathlib import Path
for line in Path('/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/plane.env').read_text().splitlines():
if line.startswith('NODEDC_INTERNAL_ACCESS_TOKEN=') or line.startswith('PLANE_NODEDC_ACCESS_TOKEN='):
value = line.split('=', 1)[1].strip().strip('"').strip("'")
if value:
print(value)
break
PY
)
DATABASE_URL='postgres://nodedc_agent_gateway:replace-with-local-postgres-password@localhost:54100/nodedc_agent_gateway' \
NODE_ENV=development \
LOG_LEVEL=silent \
NODEDC_TASKER_INTERNAL_URL='http://localhost:8090' \
NODEDC_INTERNAL_ACCESS_TOKEN="$TOKEN" \
SMOKE_WORKSPACE_SLUG='nodedc' \
SMOKE_PROJECT_ID='<project-id>' \
npm run smoke:mcp:e2e
```
Current Tasker internal adapter contract expected by Gateway:

View File

@ -23,7 +23,7 @@ Exit criteria:
## Phase 1. Agent Gateway skeleton
Status: in progress. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, product tool endpoints, local Postgres compose, and Gateway smoke checks are implemented.
Status: done in `e95cb3a`, `112522c`, `14c5f49`, `9f40207`, and the MCP transport slice. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, product tool endpoints, local Postgres compose, and Gateway smoke checks are implemented.
Create standalone service with:
@ -36,7 +36,7 @@ Create standalone service with:
- opaque token hashing;
- idempotency-key storage.
No Tasker writes yet.
Tasker writes are available through the narrow internal adapter; Gateway still must not call raw Plane routes.
## Phase 2. Launcher entitlement projection
@ -89,6 +89,8 @@ Acceptance:
- adapter rejects delete/archive;
- adapter validates labels/states/assignees.
Status: initial product slice done in Tasker commit `2ae353c`. Implemented project resolution/context, issue search/create/update/move/comment/label/assign, agent bot actor metadata, and internal token auth. `add_existing_project_member` remains planned behind the explicit `project:member:add_existing` scope.
## Phase 5. MCP server
Agent Gateway changes:
@ -109,6 +111,8 @@ Acceptance:
- local Codex can move card state;
- local Codex cannot delete/archive.
Status: initial product slice implemented. `/mcp` supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call`. REST product endpoints and MCP tools share the same runtime, scope checks, grant checks, and Tasker adapter calls. `npm run smoke:mcp:e2e` verifies real local Tasker writes.
## Phase 6. Agent identity
Tasker/Gateway integration:

View File

@ -8,6 +8,14 @@ The current Tasker / Plane fork does not expose a dedicated MCP server. It expos
Codex should not call generic Tasker REST directly.
Current implementation status:
- Agent Gateway exposes `/mcp` as JSON-RPC over HTTP.
- Implemented MCP methods: `initialize`, `ping`, `tools/list`, `tools/call`.
- `tools/list` returns only tools allowed by the authenticated agent session scopes.
- `tools/call` uses the same product runtime as REST tool endpoints.
- Server-sent event streaming is intentionally not required for the first product slice.
## Authentication
MCP clients authenticate to Agent Gateway with an opaque agent token.
@ -18,6 +26,20 @@ Recommended transport options:
- local stdio connector later if useful;
- REST fallback for non-MCP clients.
Current route:
```text
POST /mcp
```
Required request headers for authenticated tool calls:
```text
Authorization: Bearer <agent-token>
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2025-06-18
```
Token rules:
- token is opaque;

View File

@ -140,6 +140,30 @@ Add internal endpoints under a namespace such as:
/api/internal/nodedc/agent/...
```
Current implemented adapter routes:
```text
POST /api/internal/nodedc/agent/projects/resolve
GET /api/internal/nodedc/agent/projects/:project_id/context
GET /api/internal/nodedc/agent/issues?project_id=...
POST /api/internal/nodedc/agent/issues
PATCH /api/internal/nodedc/agent/issues/:issue_id
POST /api/internal/nodedc/agent/issues/:issue_id/move
POST /api/internal/nodedc/agent/issues/:issue_id/comments
PUT /api/internal/nodedc/agent/issues/:issue_id/labels
PUT /api/internal/nodedc/agent/issues/:issue_id/assignees
```
The implemented adapter uses NODE.DC internal bearer auth and receives normalized agent metadata in headers:
```text
X-NODEDC-Agent-Id
X-NODEDC-Agent-Owner-User-Id
X-NODEDC-Agent-Token-Id
```
The current adapter creates or reuses a dedicated bot actor with email `agent+<agent_id>@agents.nodedc.local` and `bot_type=nodedc_codex_agent`.
These endpoints must use `NODEDC_INTERNAL_ACCESS_TOKEN` / `PLANE_NODEDC_ACCESS_TOKEN` style auth and must be callable only from Agent Gateway.
Suggested adapter endpoints:

View File

@ -14,6 +14,8 @@
"migrate:dist": "node dist/scripts/migrate.js",
"smoke:e2e": "tsx src/scripts/smoke-e2e.ts",
"smoke:gateway": "tsx src/scripts/smoke-gateway.ts",
"smoke:mcp:e2e": "tsx src/scripts/smoke-mcp-e2e.ts",
"smoke:mcp": "tsx src/scripts/smoke-mcp.ts",
"start": "node dist/server.js"
},
"dependencies": {

View File

@ -6,6 +6,7 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
import { AgentsRepository } from "./repositories/agents.js";
import { registerAgentRoutes } from "./routes/agents.js";
import { registerHealthRoutes } from "./routes/health.js";
import { registerMcpRoutes } from "./routes/mcp.js";
import { registerToolRoutes } from "./routes/tools.js";
import { ForbiddenError } from "./security/authorization.js";
import { UnauthorizedError } from "./security/bearer.js";
@ -105,6 +106,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
await registerHealthRoutes(app, config, pool);
await registerAgentRoutes(app, { agentsRepository });
await registerToolRoutes(app, { agentsRepository, taskerClient });
await registerMcpRoutes(app, { agentsRepository, taskerClient });
return app;
}

453
src/mcp/tool-runtime.ts Normal file
View File

@ -0,0 +1,453 @@
import { z } from "zod";
import type { AgentScope } from "../domain/scopes.js";
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
import type { AgentSessionRecord } from "../repositories/agents.js";
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
import type { TaskerClient } from "../tasker/client.js";
type JsonSchema = Record<string, unknown>;
export type McpToolRuntimeDefinition = {
name: string;
title: string;
description: string;
requiredScopes: AgentScope[];
inputSchema: JsonSchema;
annotations?: Record<string, unknown>;
};
export type McpToolResult = {
content: Array<{
type: "text";
text: string;
}>;
structuredContent?: unknown;
isError?: boolean;
};
type ExecuteToolDeps = {
taskerClient: TaskerClient;
};
const emptyInputSchema = {
type: "object",
additionalProperties: false,
};
const projectInputSchema = {
type: "object",
properties: {
project_id: { type: "string" },
workspace_slug: { type: "string" },
},
required: ["project_id"],
additionalProperties: false,
};
const projectAndIssueInputSchema = {
type: "object",
properties: {
issue_id: { type: "string" },
project_id: { type: "string" },
workspace_slug: { type: "string" },
},
required: ["issue_id", "project_id"],
additionalProperties: false,
};
const structuredBlocksJsonSchema = {
type: "array",
items: {
oneOf: [
{
type: "object",
properties: {
id: { type: "string" },
type: { const: "text" },
title: { type: "string" },
body: { type: "string" },
},
required: ["id", "type"],
additionalProperties: false,
},
{
type: "object",
properties: {
id: { type: "string" },
type: { const: "checker" },
title: { type: "string" },
items: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
checked: { type: "boolean" },
},
required: ["id", "text"],
additionalProperties: false,
},
},
},
required: ["id", "type"],
additionalProperties: false,
},
],
},
};
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
{
name: "tasker_get_agent_instructions",
title: "Get Agent Instructions",
description: "Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.",
requiredScopes: ["workspace:read"],
inputSchema: emptyInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_list_projects",
title: "List Granted Projects",
description: "List Tasker projects granted to the current agent.",
requiredScopes: ["project:read"],
inputSchema: emptyInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_get_project_context",
title: "Get Project Context",
description: "Return states, labels, members, and card-writing context for one granted project.",
requiredScopes: ["project:read"],
inputSchema: projectInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_search_issues",
title: "Search Issues",
description: "Search work items inside one granted Tasker project.",
requiredScopes: ["issue:read"],
inputSchema: {
...projectInputSchema,
properties: {
...projectInputSchema.properties,
query: { type: "string" },
},
},
annotations: { readOnlyHint: true },
},
{
name: "tasker_create_issue",
title: "Create Issue",
description: "Create a Tasker card with optional NODE.DC structured text/checker blocks.",
requiredScopes: ["issue:create"],
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
workspace_slug: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
},
required: ["project_id", "title"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_update_issue",
title: "Update Issue",
description: "Patch allowed issue fields without delete, archive, or project transfer.",
requiredScopes: ["issue:update"],
inputSchema: {
type: "object",
properties: {
issue_id: { type: "string" },
project_id: { type: "string" },
workspace_slug: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
},
required: ["issue_id", "project_id"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_update_structured_blocks",
title: "Update Structured Blocks",
description: "Replace NODE.DC structured text/checker blocks in an issue detail layout.",
requiredScopes: ["issue:update", "issue:structured_blocks:write"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
structured_blocks: structuredBlocksJsonSchema,
},
required: ["issue_id", "project_id", "structured_blocks"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_move_issue",
title: "Move Issue",
description: "Move an issue to an existing state in the same granted project.",
requiredScopes: ["issue:move"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
state_id: { type: "string" },
},
required: ["issue_id", "project_id", "state_id"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_append_comment",
title: "Append Comment",
description: "Append a comment to a granted issue.",
requiredScopes: ["issue:comment"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
body: { type: "string" },
},
required: ["issue_id", "project_id", "body"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_set_issue_labels",
title: "Set Issue Labels",
description: "Replace issue labels with existing labels from the granted project.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
label_ids: { type: "array", items: { type: "string" } },
},
required: ["issue_id", "project_id", "label_ids"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: "tasker_assign_issue",
title: "Assign Issue",
description: "Replace issue assignees with existing members of the granted project.",
requiredScopes: ["issue:assign"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
member_ids: { type: "array", items: { type: "string" } },
},
required: ["issue_id", "project_id", "member_ids"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
];
const emptyArgsSchema = z.object({}).default({});
const projectArgsSchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
});
const searchIssuesArgsSchema = projectArgsSchema.extend({
query: z.string().min(1).optional(),
});
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
const createIssueArgsSchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
title: z.string().min(1).max(500),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const issueArgsSchema = z.object({
issue_id: z.string().min(1),
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
});
const updateIssueArgsSchema = issueArgsSchema.extend({
title: z.string().min(1).max(500).optional(),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const structuredBlocksArgsSchema = issueArgsSchema.extend({
structured_blocks: structuredBlocksSchema,
});
const moveIssueArgsSchema = issueArgsSchema.extend({
state_id: z.string().min(1),
});
const commentArgsSchema = issueArgsSchema.extend({
body: z.string().min(1).max(20000),
});
const labelsArgsSchema = issueArgsSchema.extend({
label_ids: z.array(z.string().min(1)).default([]),
});
const assigneesArgsSchema = issueArgsSchema.extend({
member_ids: z.array(z.string().min(1)).default([]),
});
export function getToolsForSession(session: AgentSessionRecord): McpToolRuntimeDefinition[] {
return mcpRuntimeTools.filter((tool) => tool.requiredScopes.every((scope) => hasScope(session, scope)));
}
export async function executeMcpTool(
session: AgentSessionRecord,
name: string,
rawArguments: unknown,
deps: ExecuteToolDeps
): Promise<McpToolResult> {
const args = rawArguments ?? {};
switch (name) {
case "tasker_get_agent_instructions":
emptyArgsSchema.parse(args);
requireScope(session, "workspace:read");
return asToolResult(buildAgentInstructions(session));
case "tasker_list_projects":
emptyArgsSchema.parse(args);
requireScope(session, "project:read");
return asToolResult(await deps.taskerClient.listGrantedProjects(session));
case "tasker_get_project_context": {
const input = projectArgsSchema.parse(args);
requireScope(session, "project:read");
requireProjectGrant(session, { projectId: input.project_id, workspaceSlug: input.workspace_slug });
return asToolResult(await deps.taskerClient.getProjectContext(session, input.project_id, input.workspace_slug));
}
case "tasker_search_issues": {
const input = searchIssuesArgsSchema.parse(args);
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.listIssues(session, input.project_id, input.workspace_slug, input.query));
}
case "tasker_create_issue": {
const input = createIssueArgsSchema.parse(args);
requireToolAccess(session, "issue:create", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.createIssue(session, input));
}
case "tasker_update_issue": {
const input = updateIssueArgsSchema.parse(args);
if (input.structured_blocks) {
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
} else {
requireToolAccess(session, "issue:update", input.project_id, input.workspace_slug);
}
return asToolResult(await deps.taskerClient.updateIssue(session, input.issue_id, input));
}
case "tasker_update_structured_blocks": {
const input = structuredBlocksArgsSchema.parse(args);
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
return asToolResult(
await deps.taskerClient.updateIssue(session, input.issue_id, {
project_id: input.project_id,
workspace_slug: input.workspace_slug,
structured_blocks: input.structured_blocks,
})
);
}
case "tasker_move_issue": {
const input = moveIssueArgsSchema.parse(args);
requireToolAccess(session, "issue:move", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.moveIssue(session, input.issue_id, input));
}
case "tasker_append_comment": {
const input = commentArgsSchema.parse(args);
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
}
case "tasker_set_issue_labels": {
const input = labelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.setLabels(session, input.issue_id, input));
}
case "tasker_assign_issue": {
const input = assigneesArgsSchema.parse(args);
requireToolAccess(session, "issue:assign", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.assignIssue(session, input.issue_id, input));
}
default:
throw new Error(`Unknown MCP tool: ${name}`);
}
}
function hasScope(session: AgentSessionRecord, scope: AgentScope): boolean {
return session.grants.some((grant) => grant.scopes.includes(scope));
}
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
requireProjectScopes(session, projectId, workspaceSlug, [scope]);
}
function requireProjectScopes(
session: AgentSessionRecord,
projectId: string,
workspaceSlug: string | null | undefined,
scopes: AgentScope[]
): void {
for (const scope of scopes) {
requireScope(session, scope);
}
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
for (const scope of scopes) {
if (!grant.scopes.includes(scope)) {
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
}
}
}
function asToolResult(payload: unknown): McpToolResult {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
structuredContent: payload,
isError: false,
};
}
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
return {
agent: {
id: session.agent.id,
display_name: session.agent.displayName,
owner_user_id: session.agent.ownerUserId,
},
grants: session.grants.map((grant) => ({
workspace_slug: grant.workspaceSlug,
project_id: grant.projectId,
mode: grant.mode,
scopes: grant.scopes,
})),
rules: {
card_structure: [
"Keep the main issue description concise and conceptual.",
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
"Use checker blocks for short verifiable phase items.",
"After implementation, add a factual implementation block with files touched and validation performed.",
],
hard_limits: [
"Do not delete or archive issues.",
"Do not create projects, labels, states, workspace invites, or workspace settings changes.",
"Only assign existing project members.",
"Only use projects and workspaces present in effective grants.",
],
reporting_mode: "If a grant has mode=reporting, keep issue status and comments up to date without pretending to enforce unmanaged local Codex execution.",
},
};
}

View File

@ -1,84 +1,24 @@
import type { AgentScope } from "../domain/scopes.js";
import { mcpRuntimeTools } from "./tool-runtime.js";
export type McpToolDefinition = {
name: string;
description: string;
requiredScopes: AgentScope[];
status: "planned";
status: "implemented" | "planned";
};
export const mcpToolDefinitions: McpToolDefinition[] = [
{
name: "tasker_get_agent_instructions",
description: "Return effective NODE.DC Tasker card rules, allowed projects, scopes, and reporting expectations.",
requiredScopes: ["workspace:read"],
status: "planned",
},
{
name: "tasker_list_projects",
description: "List Tasker projects granted to the current agent.",
requiredScopes: ["project:read"],
status: "planned",
},
{
name: "tasker_get_project_context",
description: "Return states, labels, members, and card-writing rules for one granted project.",
requiredScopes: ["project:read"],
status: "planned",
},
{
name: "tasker_search_issues",
description: "Search work items inside granted projects.",
requiredScopes: ["issue:read"],
status: "planned",
},
{
name: "tasker_create_issue",
description: "Create a Tasker work item with NODE.DC structured content support.",
requiredScopes: ["issue:create"],
status: "planned",
},
{
name: "tasker_update_issue",
description: "Patch allowed issue fields without delete, archive, or project transfer.",
requiredScopes: ["issue:update"],
status: "planned",
},
{
name: "tasker_move_issue",
description: "Move an issue to an existing state in the same granted project.",
requiredScopes: ["issue:move"],
status: "planned",
},
{
name: "tasker_set_issue_labels",
description: "Apply existing project labels to an issue.",
requiredScopes: ["issue:label"],
status: "planned",
},
{
name: "tasker_assign_issue",
description: "Assign existing project members to an issue.",
requiredScopes: ["issue:assign"],
status: "planned",
},
...mcpRuntimeTools.map((tool) => ({
name: tool.name,
description: tool.description,
requiredScopes: tool.requiredScopes,
status: "implemented" as const,
})),
{
name: "tasker_add_existing_project_member",
description: "Add an existing workspace member to a granted project when explicitly allowed.",
requiredScopes: ["project:member:add_existing"],
status: "planned",
},
{
name: "tasker_append_comment",
description: "Append a comment to a granted issue.",
requiredScopes: ["issue:comment"],
status: "planned",
},
{
name: "tasker_update_structured_blocks",
description: "Patch NODE.DC text/checker blocks in issue.detail_layout.",
requiredScopes: ["issue:structured_blocks:write"],
status: "planned",
},
];

194
src/routes/mcp.ts Normal file
View File

@ -0,0 +1,194 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply } from "fastify";
import { ZodError, z } from "zod";
import { executeMcpTool, getToolsForSession } from "../mcp/tool-runtime.js";
import type { AgentsRepository } from "../repositories/agents.js";
import { ForbiddenError } from "../security/authorization.js";
import { UnauthorizedError } from "../security/bearer.js";
import type { TaskerClient } from "../tasker/client.js";
import { authenticateAgent } from "./session.js";
const MCP_PROTOCOL_VERSION = "2025-06-18";
type McpRouteDeps = {
agentsRepository: AgentsRepository | null;
taskerClient: TaskerClient;
};
type JsonRpcId = string | number | null;
type JsonRpcResponse =
| {
jsonrpc: "2.0";
id: JsonRpcId;
result: unknown;
}
| {
jsonrpc: "2.0";
id: JsonRpcId;
error: {
code: number;
message: string;
data?: unknown;
};
};
const jsonRpcRequestSchema = z.object({
jsonrpc: z.literal("2.0"),
id: z.union([z.string(), z.number(), z.null()]).optional(),
method: z.string().min(1),
params: z.unknown().optional(),
});
const toolsCallParamsSchema = z.object({
name: z.string().min(1),
arguments: z.unknown().optional(),
});
export async function registerMcpRoutes(app: FastifyInstance, deps: McpRouteDeps): Promise<void> {
app.get("/mcp", async (_request, reply) =>
reply.status(405).send({
ok: false,
error: "sse_not_supported",
message: "This MCP endpoint supports JSON-RPC over HTTP POST. Server-sent events are not enabled.",
})
);
app.delete("/mcp", async (_request, reply) => reply.status(405).send({ ok: false, error: "sessions_not_stateful" }));
app.post("/mcp", async (request, reply) => {
const parsed = jsonRpcRequestSchema.safeParse(request.body);
if (!parsed.success) {
return sendJsonRpc(reply, makeError(null, -32600, "Invalid JSON-RPC request.", parsed.error.issues));
}
const message = parsed.data;
const id = message.id ?? null;
if (message.id === undefined) {
return reply.status(202).send();
}
try {
switch (message.method) {
case "initialize":
reply.header("Mcp-Session-Id", randomUUID());
return sendJsonRpc(reply, {
jsonrpc: "2.0",
id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {
tools: {
listChanged: false,
},
},
serverInfo: {
name: "nodedc-tasker-codex-api",
version: "0.1.0",
},
},
});
case "ping":
return sendJsonRpc(reply, { jsonrpc: "2.0", id, result: {} });
case "tools/list": {
const session = await authenticateAgent(request, deps);
return sendJsonRpc(reply, {
jsonrpc: "2.0",
id,
result: {
tools: getToolsForSession(session).map((tool) => ({
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
annotations: tool.annotations,
})),
},
});
}
case "tools/call": {
const session = await authenticateAgent(request, deps);
const params = toolsCallParamsSchema.parse(message.params);
const result = await executeMcpTool(session, params.name, params.arguments, deps);
return sendJsonRpc(reply, {
jsonrpc: "2.0",
id,
result,
});
}
default:
return sendJsonRpc(reply, makeError(id, -32601, `Method not found: ${message.method}.`));
}
} catch (error) {
return sendJsonRpc(reply, mapError(id, error));
}
});
}
function sendJsonRpc(reply: FastifyReply, response: JsonRpcResponse): FastifyReply {
reply.header("Content-Type", "application/json");
return reply.send(response);
}
function mapError(id: JsonRpcId, error: unknown): JsonRpcResponse {
if (error instanceof ZodError) {
return makeError(id, -32602, "Invalid MCP tool arguments.", error.issues);
}
if (error instanceof UnauthorizedError) {
return makeError(id, -32001, error.message);
}
if (error instanceof ForbiddenError) {
return makeToolExecutionError(id, "forbidden", error.message);
}
if (error instanceof Error && error.message.startsWith("Unknown MCP tool:")) {
return makeError(id, -32602, error.message);
}
if (error instanceof Error) {
return makeToolExecutionError(id, error.name || "tool_execution_error", error.message);
}
return makeToolExecutionError(id, "tool_execution_error", "MCP tool execution failed.");
}
function makeError(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse {
return {
jsonrpc: "2.0",
id,
error: {
code,
message,
data,
},
};
}
function makeToolExecutionError(id: JsonRpcId, code: string, message: string): JsonRpcResponse {
const payload = {
ok: false,
error: code,
message,
};
return {
jsonrpc: "2.0",
id,
result: {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
structuredContent: payload,
isError: true,
},
};
}

View File

@ -1,10 +1,7 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { AgentScope } from "../domain/scopes.js";
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
import { executeMcpTool } from "../mcp/tool-runtime.js";
import type { AgentsRepository } from "../repositories/agents.js";
import type { TaskerClient } from "../tasker/client.js";
import { authenticateAgent } from "./session.js";
@ -13,188 +10,114 @@ type ToolRouteDeps = {
taskerClient: TaskerClient;
};
const projectParamsSchema = z.object({
projectId: z.string().min(1),
});
const issueParamsSchema = z.object({
issueId: z.string().min(1),
});
const listIssuesQuerySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).optional(),
query: z.string().min(1).optional(),
});
const projectContextQuerySchema = z.object({
workspace_slug: z.string().min(1).optional(),
});
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
const createIssueBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
title: z.string().min(1).max(500),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const updateIssueBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
title: z.string().min(1).max(500).optional(),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const moveIssueBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
state_id: z.string().min(1),
});
const commentBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
body: z.string().min(1).max(20000),
});
const labelsBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
label_ids: z.array(z.string().min(1)).default([]),
});
const assigneesBodySchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
member_ids: z.array(z.string().min(1)).default([]),
});
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
app.get("/api/v1/tools/projects", async (request) => {
const session = await authenticateAgent(request, deps);
requireScope(session, "project:read");
return deps.taskerClient.listGrantedProjects(session);
const result = await executeMcpTool(session, "tasker_list_projects", {}, deps);
return result.structuredContent;
});
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
const session = await authenticateAgent(request, deps);
const { projectId } = projectParamsSchema.parse(request.params);
const query = projectContextQuerySchema.parse(request.query);
requireScope(session, "project:read");
requireProjectGrant(session, { projectId, workspaceSlug: query.workspace_slug });
return deps.taskerClient.getProjectContext(session, projectId, query.workspace_slug);
const params = request.params as { projectId: string };
const query = request.query as { workspace_slug?: string };
const result = await executeMcpTool(
session,
"tasker_get_project_context",
{
project_id: params.projectId,
workspace_slug: query.workspace_slug,
},
deps
);
return result.structuredContent;
});
app.get("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const query = listIssuesQuerySchema.parse(request.query);
requireScope(session, "issue:read");
requireProjectGrant(session, { projectId: query.project_id, workspaceSlug: query.workspace_slug });
return deps.taskerClient.listIssues(session, query.project_id, query.workspace_slug, query.query);
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
const result = await executeMcpTool(session, "tasker_search_issues", query, deps);
return result.structuredContent;
});
app.post("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const body = createIssueBodySchema.parse(request.body);
requireToolAccess(session, "issue:create", body.project_id, body.workspace_slug);
return deps.taskerClient.createIssue(session, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
title: body.title,
description: body.description,
priority: body.priority,
structured_blocks: body.structured_blocks,
});
const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps);
return result.structuredContent;
});
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = updateIssueBodySchema.parse(request.body);
requireToolAccess(session, "issue:update", body.project_id, body.workspace_slug);
if (body.structured_blocks) {
requireScope(session, "issue:structured_blocks:write");
}
return deps.taskerClient.updateIssue(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
title: body.title,
description: body.description,
priority: body.priority,
structured_blocks: body.structured_blocks,
});
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_update_issue",
{
...(request.body as Record<string, unknown>),
issue_id: params.issueId,
},
deps
);
return result.structuredContent;
});
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = moveIssueBodySchema.parse(request.body);
requireToolAccess(session, "issue:move", body.project_id, body.workspace_slug);
return deps.taskerClient.moveIssue(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
state_id: body.state_id,
});
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_move_issue",
{
...(request.body as Record<string, unknown>),
issue_id: params.issueId,
},
deps
);
return result.structuredContent;
});
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = commentBodySchema.parse(request.body);
requireToolAccess(session, "issue:comment", body.project_id, body.workspace_slug);
return deps.taskerClient.appendComment(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
body: body.body,
});
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_append_comment",
{
...(request.body as Record<string, unknown>),
issue_id: params.issueId,
},
deps
);
return result.structuredContent;
});
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = labelsBodySchema.parse(request.body);
requireToolAccess(session, "issue:label", body.project_id, body.workspace_slug);
return deps.taskerClient.setLabels(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
label_ids: body.label_ids,
});
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_set_issue_labels",
{
...(request.body as Record<string, unknown>),
issue_id: params.issueId,
},
deps
);
return result.structuredContent;
});
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
const session = await authenticateAgent(request, deps);
const { issueId } = issueParamsSchema.parse(request.params);
const body = assigneesBodySchema.parse(request.body);
requireToolAccess(session, "issue:assign", body.project_id, body.workspace_slug);
return deps.taskerClient.assignIssue(session, issueId, {
project_id: body.project_id,
workspace_slug: body.workspace_slug,
member_ids: body.member_ids,
});
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_assign_issue",
{
...(request.body as Record<string, unknown>),
issue_id: params.issueId,
},
deps
);
return result.structuredContent;
});
}
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
requireScope(session, scope);
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
if (!grant.scopes.includes(scope)) {
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
}
}

View File

@ -0,0 +1,257 @@
import { Pool } from "pg";
import { buildApp } from "../app.js";
import { loadConfig } from "../config.js";
import { runMigrations } from "../db/migrations.js";
const config = loadConfig({
...process.env,
LOG_LEVEL: process.env.LOG_LEVEL === "debug" ? "debug" : "silent",
});
const workspaceSlug = readRequiredEnv("SMOKE_WORKSPACE_SLUG");
const projectId = readRequiredEnv("SMOKE_PROJECT_ID");
if (!config.DATABASE_URL) {
throw new Error("DATABASE_URL is required for MCP e2e smoke test.");
}
if (!config.NODEDC_INTERNAL_ACCESS_TOKEN) {
throw new Error("NODEDC_INTERNAL_ACCESS_TOKEN is required for MCP e2e smoke test.");
}
const migrationPool = new Pool({ connectionString: config.DATABASE_URL });
await runMigrations(migrationPool);
await migrationPool.end();
const app = await buildApp(config);
try {
const suffix = Date.now().toString(36);
const agentId = await createAgent(suffix);
await upsertGrant(agentId);
const token = await createToken(agentId);
const headers = {
Authorization: `Bearer ${token}`,
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": "2025-06-18",
};
await mcpRequest(1, "initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: {
name: "nodedc-mcp-e2e-smoke",
version: "0.1.0",
},
});
const toolsList = await mcpRequest(2, "tools/list", {}, headers);
const toolNames = toolsList.result.tools.map((tool: { name: string }) => tool.name);
assert(toolNames.includes("tasker_create_issue"), "create issue tool is listed with full grant");
const projects = await callTool(3, "tasker_list_projects", {}, headers);
const context = await callTool(
4,
"tasker_get_project_context",
{
project_id: projectId,
workspace_slug: workspaceSlug,
},
headers
);
const createIssue = await callTool(
5,
"tasker_create_issue",
{
project_id: projectId,
workspace_slug: workspaceSlug,
title: `NODE.DC MCP Codex smoke ${suffix}`,
description: "Created through MCP Streamable HTTP JSON-RPC.",
priority: "medium",
structured_blocks: [
{
id: "mcp-current-architecture",
type: "text",
title: "Текущая архитектура",
body: "MCP client called Agent Gateway; Gateway routed into real Tasker internal adapter.",
},
{
id: "mcp-checker",
type: "checker",
title: "Чекер MCP smoke",
items: [
{
id: "mcp",
text: "MCP tools/call accepted",
checked: true,
},
{
id: "tasker",
text: "Real Tasker issue created",
checked: true,
},
],
},
],
},
headers
);
const issueId = createIssue.structuredContent.issue.id as string;
await callTool(
6,
"tasker_append_comment",
{
issue_id: issueId,
project_id: projectId,
workspace_slug: workspaceSlug,
body: "MCP e2e smoke comment from Agent Gateway.",
},
headers
);
const states = Array.isArray(context.structuredContent.states) ? context.structuredContent.states : [];
const targetState = states.find((state: any) => typeof state?.id === "string");
if (targetState) {
await callTool(
7,
"tasker_move_issue",
{
issue_id: issueId,
project_id: projectId,
workspace_slug: workspaceSlug,
state_id: targetState.id,
},
headers
);
}
console.log(
JSON.stringify(
{
ok: true,
protocol: "mcp-streamable-http-json-rpc",
tasker_url: config.NODEDC_TASKER_INTERNAL_URL,
workspace_slug: workspaceSlug,
project_id: projectId,
visible_projects: Array.isArray(projects.structuredContent.projects) ? projects.structuredContent.projects.length : null,
issue_id: issueId,
moved: Boolean(targetState),
},
null,
2
)
);
} finally {
await app.close();
}
async function createAgent(suffix: string): Promise<string> {
const response = await app.inject({
method: "POST",
url: "/api/v1/agents",
payload: {
owner_user_id: `mcp-e2e-owner-${suffix}`,
owner_email: `mcp-e2e-${suffix}@example.test`,
display_name: `MCP E2E Codex ${suffix}`,
},
});
assertStatus(response.statusCode, 201, response.body);
return JSON.parse(response.body).agent.id;
}
async function upsertGrant(agentId: string): Promise<void> {
const response = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/grants`,
payload: {
workspace_slug: workspaceSlug,
project_id: projectId,
scopes: [
"workspace:read",
"project:read",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:structured_blocks:write",
],
mode: "voluntary",
created_by_user_id: "mcp-e2e-smoke-admin",
},
});
assertStatus(response.statusCode, 201, response.body);
}
async function createToken(agentId: string): Promise<string> {
const response = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/tokens`,
payload: {
name: "MCP e2e smoke token",
},
});
assertStatus(response.statusCode, 201, response.body);
return JSON.parse(response.body).token;
}
async function callTool(id: number, name: string, toolArguments: unknown, headers: Record<string, string>): Promise<any> {
const payload = await mcpRequest(
id,
"tools/call",
{
name,
arguments: toolArguments,
},
headers
);
if (payload.result?.isError) {
throw new Error(`MCP tool failed: ${JSON.stringify(payload.result.structuredContent)}`);
}
return payload.result;
}
async function mcpRequest(id: number, method: string, params?: unknown, headers?: Record<string, string>): Promise<any> {
const response = await app.inject({
method: "POST",
url: "/mcp",
headers,
payload: {
jsonrpc: "2.0",
id,
method,
params,
},
});
assertStatus(response.statusCode, 200, response.body);
const payload = JSON.parse(response.body);
if (payload.error) {
throw new Error(`MCP error for ${method}: ${JSON.stringify(payload.error)}`);
}
return payload;
}
function assertStatus(actual: number, expected: number, body: string): void {
if (actual !== expected) {
throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`);
}
}
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(`MCP e2e smoke assertion failed: ${message}`);
}
}
function readRequiredEnv(key: string): string {
const value = process.env[key]?.trim();
if (!value) {
throw new Error(`${key} is required for MCP e2e smoke test.`);
}
return value;
}

218
src/scripts/smoke-mcp.ts Normal file
View File

@ -0,0 +1,218 @@
import { Pool } from "pg";
import { buildApp } from "../app.js";
import { loadConfig } from "../config.js";
import { runMigrations } from "../db/migrations.js";
const envWithoutTaskerToken = { ...process.env };
delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN;
envWithoutTaskerToken.LOG_LEVEL = "silent";
const config = loadConfig(envWithoutTaskerToken);
if (!config.DATABASE_URL) {
throw new Error("DATABASE_URL is required for MCP smoke test.");
}
const migrationPool = new Pool({ connectionString: config.DATABASE_URL });
await runMigrations(migrationPool);
await migrationPool.end();
const app = await buildApp(config);
try {
const suffix = Date.now().toString(36);
const projectId = `mcp-project-${suffix}`;
const workspaceSlug = `mcp-workspace-${suffix}`;
const agentId = await createAgent(suffix);
await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read"]);
const token = await createToken(agentId);
const headers = {
Authorization: `Bearer ${token}`,
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": "2025-06-18",
};
const initialize = await mcpRequest(1, "initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: {
name: "nodedc-smoke",
version: "0.1.0",
},
});
assert(initialize.result?.capabilities?.tools, "initialize exposes tools capability");
const toolsList = await mcpRequest(2, "tools/list", {}, headers);
const toolNames = toolsList.result.tools.map((tool: { name: string }) => tool.name);
assert(toolNames.includes("tasker_get_agent_instructions"), "instructions tool is listed");
assert(!toolNames.includes("tasker_create_issue"), "create tool is hidden without issue:create scope");
const instructions = await mcpRequest(
3,
"tools/call",
{
name: "tasker_get_agent_instructions",
arguments: {},
},
headers
);
assert(instructions.result?.structuredContent?.rules, "instructions tool returns structured rules");
const deniedCreate = await mcpRequest(
4,
"tools/call",
{
name: "tasker_create_issue",
arguments: {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Should be denied by MCP smoke",
},
},
headers
);
assert(deniedCreate.result?.isError === true, "create call returns MCP tool error without issue:create scope");
await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:update"]);
await upsertGrant(agentId, workspaceSlug, `${projectId}-structured`, [
"workspace:read",
"project:read",
"issue:structured_blocks:write",
]);
const deniedSplitGrantStructuredUpdate = await mcpRequest(
5,
"tools/call",
{
name: "tasker_update_structured_blocks",
arguments: {
issue_id: "split-grant-issue",
project_id: projectId,
workspace_slug: workspaceSlug,
structured_blocks: [],
},
},
headers
);
assert(
deniedSplitGrantStructuredUpdate.result?.structuredContent?.error === "forbidden",
"structured block writes require both scopes on the same project grant"
);
await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:create"]);
const allowedButNoAdapter = await mcpRequest(
6,
"tools/call",
{
name: "tasker_create_issue",
arguments: {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Allowed by MCP, waiting for Tasker adapter token",
},
},
headers
);
assert(allowedButNoAdapter.result?.isError === true, "allowed create reaches Tasker boundary");
assert(
allowedButNoAdapter.result?.structuredContent?.error === "TaskerAdapterNotConfiguredError",
"Tasker boundary error is explicit"
);
console.log(
JSON.stringify(
{
ok: true,
agent_id: agentId,
token_prefix: token.split("_")[0],
tools_listed: toolNames.length,
checks: {
initialize: "passed",
tools_list: "passed",
instructions_call: "passed",
denied_without_scope: "passed",
denied_split_grant_structured_blocks: "passed",
allowed_request_reaches_tasker_boundary: "passed",
},
},
null,
2
)
);
} finally {
await app.close();
}
async function createAgent(suffix: string): Promise<string> {
const response = await app.inject({
method: "POST",
url: "/api/v1/agents",
payload: {
owner_user_id: `mcp-owner-${suffix}`,
owner_email: `mcp-${suffix}@example.test`,
display_name: `MCP Codex ${suffix}`,
},
});
assertStatus(response.statusCode, 201, response.body);
return JSON.parse(response.body).agent.id;
}
async function upsertGrant(agentId: string, workspaceSlug: string, projectId: string, scopes: string[]): Promise<void> {
const response = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/grants`,
payload: {
workspace_slug: workspaceSlug,
project_id: projectId,
scopes,
mode: "voluntary",
created_by_user_id: "mcp-smoke-admin",
},
});
assertStatus(response.statusCode, 201, response.body);
}
async function createToken(agentId: string): Promise<string> {
const response = await app.inject({
method: "POST",
url: `/api/v1/agents/${agentId}/tokens`,
payload: {
name: "MCP smoke token",
},
});
assertStatus(response.statusCode, 201, response.body);
return JSON.parse(response.body).token;
}
async function mcpRequest(id: number, method: string, params?: unknown, headers?: Record<string, string>): Promise<any> {
const response = await app.inject({
method: "POST",
url: "/mcp",
headers,
payload: {
jsonrpc: "2.0",
id,
method,
params,
},
});
assertStatus(response.statusCode, 200, response.body);
const payload = JSON.parse(response.body);
if (payload.error) {
throw new Error(`MCP error for ${method}: ${JSON.stringify(payload.error)}`);
}
return payload;
}
function assertStatus(actual: number, expected: number, body: string): void {
if (actual !== expected) {
throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`);
}
}
function assert(condition: unknown, message: string): void {
if (!condition) {
throw new Error(`MCP smoke assertion failed: ${message}`);
}
}