241 lines
7.0 KiB
TypeScript
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}`);
|
|
}
|
|
}
|