NODEDC_TASKMANAGER_CODEXAPI/src/scripts/smoke-mcp.ts

241 lines
7.0 KiB
TypeScript

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",
idempotency_key: `mcp-denied-create-${suffix}`,
},
},
headers
);
assert(deniedCreate.result?.isError === true, "create call returns MCP tool error without issue:create scope");
const missingIdempotencyKey = await mcpRequest(
5,
"tools/call",
{
name: "tasker_create_issue",
arguments: {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Should be denied before write because idempotency key is missing",
},
},
headers
);
assert(
missingIdempotencyKey.result?.structuredContent?.error === "idempotency_key_required",
"write tools require idempotency key"
);
await upsertGrant(agentId, workspaceSlug, projectId, ["workspace:read", "project:read", "issue:read", "issue:update"]);
await upsertGrant(agentId, workspaceSlug, `${projectId}-structured`, [
"workspace:read",
"project:read",
"issue:structured_blocks:write",
]);
const deniedSplitGrantStructuredUpdate = await mcpRequest(
6,
"tools/call",
{
name: "tasker_update_structured_blocks",
arguments: {
issue_id: "split-grant-issue",
project_id: projectId,
workspace_slug: workspaceSlug,
structured_blocks: [],
idempotency_key: `mcp-split-grant-${suffix}`,
},
},
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(
7,
"tools/call",
{
name: "tasker_create_issue",
arguments: {
project_id: projectId,
workspace_slug: workspaceSlug,
title: "Allowed by MCP, waiting for Tasker adapter token",
idempotency_key: `mcp-allowed-no-adapter-${suffix}`,
},
},
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",
idempotency_required: "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}`);
}
}