API - CODEX AGENTS: MCP transport and real e2e smoke
This commit is contained in:
parent
418914fefd
commit
c9519b52d2
48
README.md
48
README.md
|
|
@ -27,7 +27,9 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are
|
||||||
- Opaque agent tokens are generated once and stored only as SHA-256 hashes.
|
- 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.
|
- 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 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
|
## Local development
|
||||||
|
|
||||||
|
|
@ -44,6 +46,7 @@ Useful checks:
|
||||||
```bash
|
```bash
|
||||||
npm run check
|
npm run check
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run smoke:mcp
|
||||||
npm run smoke:gateway
|
npm run smoke:gateway
|
||||||
curl http://127.0.0.1:4100/readyz
|
curl http://127.0.0.1:4100/readyz
|
||||||
curl http://127.0.0.1:4100/api/v1/meta/capabilities
|
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.
|
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
|
## Local testing strategy
|
||||||
|
|
||||||
No fake Tasker storage is embedded into Agent Gateway.
|
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:gateway` verifies real Agent Gateway persistence, bearer token auth, scope checks, grant checks, and the boundary before Tasker calls.
|
1. `npm run smoke:mcp` verifies MCP initialize, tool listing, bearer token auth, scope checks, grant checks, and the Tasker boundary.
|
||||||
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.
|
2. `npm run smoke:gateway` verifies the REST compatibility boundary over the same tool execution path.
|
||||||
3. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced.
|
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:
|
Current Tasker internal adapter contract expected by Gateway:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Exit criteria:
|
||||||
|
|
||||||
## Phase 1. Agent Gateway skeleton
|
## 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:
|
Create standalone service with:
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ Create standalone service with:
|
||||||
- opaque token hashing;
|
- opaque token hashing;
|
||||||
- idempotency-key storage.
|
- 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
|
## Phase 2. Launcher entitlement projection
|
||||||
|
|
||||||
|
|
@ -89,6 +89,8 @@ Acceptance:
|
||||||
- adapter rejects delete/archive;
|
- adapter rejects delete/archive;
|
||||||
- adapter validates labels/states/assignees.
|
- 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
|
## Phase 5. MCP server
|
||||||
|
|
||||||
Agent Gateway changes:
|
Agent Gateway changes:
|
||||||
|
|
@ -109,6 +111,8 @@ 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.
|
||||||
|
|
||||||
## Phase 6. Agent identity
|
## Phase 6. Agent identity
|
||||||
|
|
||||||
Tasker/Gateway integration:
|
Tasker/Gateway integration:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Authentication
|
||||||
|
|
||||||
MCP clients authenticate to Agent Gateway with an opaque agent token.
|
MCP clients authenticate to Agent Gateway with an opaque agent token.
|
||||||
|
|
@ -18,6 +26,20 @@ Recommended transport options:
|
||||||
- local stdio connector later if useful;
|
- local stdio connector later if useful;
|
||||||
- REST fallback for non-MCP clients.
|
- 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 rules:
|
||||||
|
|
||||||
- token is opaque;
|
- token is opaque;
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,30 @@ Add internal endpoints under a namespace such as:
|
||||||
/api/internal/nodedc/agent/...
|
/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.
|
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:
|
Suggested adapter endpoints:
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
"migrate:dist": "node dist/scripts/migrate.js",
|
"migrate:dist": "node dist/scripts/migrate.js",
|
||||||
"smoke:e2e": "tsx src/scripts/smoke-e2e.ts",
|
"smoke:e2e": "tsx src/scripts/smoke-e2e.ts",
|
||||||
"smoke:gateway": "tsx src/scripts/smoke-gateway.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"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.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";
|
||||||
|
import { registerMcpRoutes } from "./routes/mcp.js";
|
||||||
import { registerToolRoutes } from "./routes/tools.js";
|
import { registerToolRoutes } from "./routes/tools.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";
|
||||||
|
|
@ -105,6 +106,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
await registerHealthRoutes(app, config, pool);
|
await registerHealthRoutes(app, config, pool);
|
||||||
await registerAgentRoutes(app, { agentsRepository });
|
await registerAgentRoutes(app, { agentsRepository });
|
||||||
await registerToolRoutes(app, { agentsRepository, taskerClient });
|
await registerToolRoutes(app, { agentsRepository, taskerClient });
|
||||||
|
await registerMcpRoutes(app, { agentsRepository, taskerClient });
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,84 +1,24 @@
|
||||||
import type { AgentScope } from "../domain/scopes.js";
|
import type { AgentScope } from "../domain/scopes.js";
|
||||||
|
import { mcpRuntimeTools } from "./tool-runtime.js";
|
||||||
|
|
||||||
export type McpToolDefinition = {
|
export type McpToolDefinition = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
requiredScopes: AgentScope[];
|
requiredScopes: AgentScope[];
|
||||||
status: "planned";
|
status: "implemented" | "planned";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mcpToolDefinitions: McpToolDefinition[] = [
|
export const mcpToolDefinitions: McpToolDefinition[] = [
|
||||||
{
|
...mcpRuntimeTools.map((tool) => ({
|
||||||
name: "tasker_get_agent_instructions",
|
name: tool.name,
|
||||||
description: "Return effective NODE.DC Tasker card rules, allowed projects, scopes, and reporting expectations.",
|
description: tool.description,
|
||||||
requiredScopes: ["workspace:read"],
|
requiredScopes: tool.requiredScopes,
|
||||||
status: "planned",
|
status: "implemented" as const,
|
||||||
},
|
})),
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "tasker_add_existing_project_member",
|
name: "tasker_add_existing_project_member",
|
||||||
description: "Add an existing workspace member to a granted project when explicitly allowed.",
|
description: "Add an existing workspace member to a granted project when explicitly allowed.",
|
||||||
requiredScopes: ["project:member:add_existing"],
|
requiredScopes: ["project:member:add_existing"],
|
||||||
status: "planned",
|
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",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import type { AgentScope } from "../domain/scopes.js";
|
import { executeMcpTool } from "../mcp/tool-runtime.js";
|
||||||
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
|
import type { AgentsRepository } from "../repositories/agents.js";
|
||||||
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
|
|
||||||
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
|
|
||||||
import type { TaskerClient } from "../tasker/client.js";
|
import type { TaskerClient } from "../tasker/client.js";
|
||||||
import { authenticateAgent } from "./session.js";
|
import { authenticateAgent } from "./session.js";
|
||||||
|
|
||||||
|
|
@ -13,188 +10,114 @@ type ToolRouteDeps = {
|
||||||
taskerClient: TaskerClient;
|
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> {
|
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);
|
||||||
requireScope(session, "project:read");
|
const result = await executeMcpTool(session, "tasker_list_projects", {}, deps);
|
||||||
|
return result.structuredContent;
|
||||||
return deps.taskerClient.listGrantedProjects(session);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
|
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const { projectId } = projectParamsSchema.parse(request.params);
|
const params = request.params as { projectId: string };
|
||||||
const query = projectContextQuerySchema.parse(request.query);
|
const query = request.query as { workspace_slug?: string };
|
||||||
requireScope(session, "project:read");
|
const result = await executeMcpTool(
|
||||||
requireProjectGrant(session, { projectId, workspaceSlug: query.workspace_slug });
|
session,
|
||||||
|
"tasker_get_project_context",
|
||||||
return deps.taskerClient.getProjectContext(session, projectId, query.workspace_slug);
|
{
|
||||||
|
project_id: params.projectId,
|
||||||
|
workspace_slug: query.workspace_slug,
|
||||||
|
},
|
||||||
|
deps
|
||||||
|
);
|
||||||
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = listIssuesQuerySchema.parse(request.query);
|
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
|
||||||
requireScope(session, "issue:read");
|
const result = await executeMcpTool(session, "tasker_search_issues", query, deps);
|
||||||
requireProjectGrant(session, { projectId: query.project_id, workspaceSlug: query.workspace_slug });
|
return result.structuredContent;
|
||||||
|
|
||||||
return deps.taskerClient.listIssues(session, query.project_id, query.workspace_slug, query.query);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 body = createIssueBodySchema.parse(request.body);
|
const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps);
|
||||||
requireToolAccess(session, "issue:create", body.project_id, body.workspace_slug);
|
return result.structuredContent;
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
|
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const { issueId } = issueParamsSchema.parse(request.params);
|
const params = request.params as { issueId: string };
|
||||||
const body = updateIssueBodySchema.parse(request.body);
|
const result = await executeMcpTool(
|
||||||
requireToolAccess(session, "issue:update", body.project_id, body.workspace_slug);
|
session,
|
||||||
|
"tasker_update_issue",
|
||||||
if (body.structured_blocks) {
|
{
|
||||||
requireScope(session, "issue:structured_blocks:write");
|
...(request.body as Record<string, unknown>),
|
||||||
}
|
issue_id: params.issueId,
|
||||||
|
},
|
||||||
return deps.taskerClient.updateIssue(session, issueId, {
|
deps
|
||||||
project_id: body.project_id,
|
);
|
||||||
workspace_slug: body.workspace_slug,
|
return result.structuredContent;
|
||||||
title: body.title,
|
|
||||||
description: body.description,
|
|
||||||
priority: body.priority,
|
|
||||||
structured_blocks: body.structured_blocks,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
|
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const { issueId } = issueParamsSchema.parse(request.params);
|
const params = request.params as { issueId: string };
|
||||||
const body = moveIssueBodySchema.parse(request.body);
|
const result = await executeMcpTool(
|
||||||
requireToolAccess(session, "issue:move", body.project_id, body.workspace_slug);
|
session,
|
||||||
|
"tasker_move_issue",
|
||||||
return deps.taskerClient.moveIssue(session, issueId, {
|
{
|
||||||
project_id: body.project_id,
|
...(request.body as Record<string, unknown>),
|
||||||
workspace_slug: body.workspace_slug,
|
issue_id: params.issueId,
|
||||||
state_id: body.state_id,
|
},
|
||||||
});
|
deps
|
||||||
|
);
|
||||||
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
|
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const { issueId } = issueParamsSchema.parse(request.params);
|
const params = request.params as { issueId: string };
|
||||||
const body = commentBodySchema.parse(request.body);
|
const result = await executeMcpTool(
|
||||||
requireToolAccess(session, "issue:comment", body.project_id, body.workspace_slug);
|
session,
|
||||||
|
"tasker_append_comment",
|
||||||
return deps.taskerClient.appendComment(session, issueId, {
|
{
|
||||||
project_id: body.project_id,
|
...(request.body as Record<string, unknown>),
|
||||||
workspace_slug: body.workspace_slug,
|
issue_id: params.issueId,
|
||||||
body: body.body,
|
},
|
||||||
});
|
deps
|
||||||
|
);
|
||||||
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
|
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const { issueId } = issueParamsSchema.parse(request.params);
|
const params = request.params as { issueId: string };
|
||||||
const body = labelsBodySchema.parse(request.body);
|
const result = await executeMcpTool(
|
||||||
requireToolAccess(session, "issue:label", body.project_id, body.workspace_slug);
|
session,
|
||||||
|
"tasker_set_issue_labels",
|
||||||
return deps.taskerClient.setLabels(session, issueId, {
|
{
|
||||||
project_id: body.project_id,
|
...(request.body as Record<string, unknown>),
|
||||||
workspace_slug: body.workspace_slug,
|
issue_id: params.issueId,
|
||||||
label_ids: body.label_ids,
|
},
|
||||||
});
|
deps
|
||||||
|
);
|
||||||
|
return result.structuredContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
|
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
|
||||||
const session = await authenticateAgent(request, deps);
|
const session = await authenticateAgent(request, deps);
|
||||||
const { issueId } = issueParamsSchema.parse(request.params);
|
const params = request.params as { issueId: string };
|
||||||
const body = assigneesBodySchema.parse(request.body);
|
const result = await executeMcpTool(
|
||||||
requireToolAccess(session, "issue:assign", body.project_id, body.workspace_slug);
|
session,
|
||||||
|
"tasker_assign_issue",
|
||||||
return deps.taskerClient.assignIssue(session, issueId, {
|
{
|
||||||
project_id: body.project_id,
|
...(request.body as Record<string, unknown>),
|
||||||
workspace_slug: body.workspace_slug,
|
issue_id: params.issueId,
|
||||||
member_ids: body.member_ids,
|
},
|
||||||
});
|
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}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue