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.
|
||||
- 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { 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",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { 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}.`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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