0c15d3d0b2
M2 delivers the read-only MCP capability broker gateway and four Day-1 infrastructure adapters (Proxmox, SSH/Linux, GitHub, Gitea). 13 REQs (015-027) all pass. 656 tests green. M1 non-regression verified. MCP spec 2025-06-18 conformance verified (PROTOCOL.md + 7 tests). Defense-in-depth SSH (broker layer 1 + Relay Agent layer 2 + no-shell exec). Two-track LLM smoke (Track A mock-path P0 gate passes). CI: Gitea Actions (.gitea/workflows/ci.yml) with Postgres 16 + RLS verification. Phases shipped: P0 pre-execution v0.1.0 P1 Wave F — MCP gateway v0.1.1 P2 Wave G — Proxmox v0.1.2 P3 Wave H — SSH/Linux v0.1.3 P4 Wave I — Git adapters v0.1.4 P5 Wave J — SSE+smoke+UI v0.1.5 P6 Final — review+ship v0.1.6 ← milestone release ---ci--- phase: 6 milestone: v0.2 status: complete phase_role: final milestone_complete: true requirements: covered: [REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027] partial: [] ---/ci---
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
/**
|
|
* mcp-routes.test.ts — MCP API routes (Wave F, Task 9).
|
|
*
|
|
* Boots PGlite + migrations + provisions a tenant + admin session, then
|
|
* exercises the route handlers directly (constructing NextRequest with the
|
|
* session cookie). Covers:
|
|
* - GET /api/mcp/tools returns the closed 9-tool set (MCP tools/list facade).
|
|
* - POST /api/mcp/adapter persists an adapter + audit event (adapter.configured).
|
|
* - GET /api/mcp/adapter lists the tenant's adapters (RLS-scoped).
|
|
* - PATCH/DELETE /api/mcp/adapter/[id] update/remove.
|
|
* - POST /api/mcp/invoke returns {correlationId, streamUrl}; 400 on invalid
|
|
* args (Edge 4); 404 adapter_not_found.
|
|
*
|
|
* The broker + stream-manager unit tests cover the deeper enforcement order;
|
|
* this test exercises the HTTP surface + auth + audit integration.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
import { withTenant, type DbClient, type ScopedClient } from "@coreci/db";
|
|
import { provisionTenant, createSession } from "@coreci/auth";
|
|
|
|
import { GET as getTools } from "../app/api/mcp/tools/route.js";
|
|
import { POST as postInvoke } from "../app/api/mcp/invoke/route.js";
|
|
import { POST as postAdapter, GET as getAdapter } from "../app/api/mcp/adapter/route.js";
|
|
import { PATCH as patchAdapter, DELETE as deleteAdapter } from "../app/api/mcp/adapter/[id]/route.js";
|
|
import { getMcpRuntime, setMcpRuntime } from "../lib/mcp.js";
|
|
import { getDb } from "../lib/db.js";
|
|
|
|
const SESSION_SIGNING_KEY = "mcp-routes-test-session-signing-key";
|
|
const MASTER_KEY = "mcp-routes-test-master-key-32+chars-long";
|
|
|
|
let db: DbClient;
|
|
let admin: { tenantId: string; userId: string; token: string } | null = null;
|
|
|
|
async function seedTenant(orgId: string, email: string): Promise<{
|
|
tenantId: string;
|
|
userId: string;
|
|
token: string;
|
|
}> {
|
|
const p = await provisionTenant(db, orgId, `workos_${orgId}`, email, `Tenant ${orgId}`);
|
|
const { token } = await createSession(db, SESSION_SIGNING_KEY, p.userId, p.tenantId, p.role, {
|
|
lifetimeSeconds: 3600,
|
|
});
|
|
return { tenantId: p.tenantId, userId: p.userId, token };
|
|
}
|
|
|
|
function authedReq(path: string, token: string, method = "GET", body?: unknown): NextRequest {
|
|
const url = new URL(path, "http://localhost:3000");
|
|
const headers: Record<string, string> = { cookie: `coreci_session=${token}` };
|
|
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
|
|
if (body !== undefined) {
|
|
headers["content-type"] = "application/json";
|
|
init.body = JSON.stringify(body);
|
|
}
|
|
return new NextRequest(url, init);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
process.env.SESSION_SIGNING_KEY = SESSION_SIGNING_KEY;
|
|
process.env.SECRET_MASTER_KEY_DEV = MASTER_KEY;
|
|
// Mock global fetch so submit-time validation succeeds without a live
|
|
// upstream in CI:
|
|
// - Proxmox (Wave G, REQ-025): GET /api2/json/version + GET /api2/json/nodes.
|
|
// - GitHub (Wave I, D-006/R-004): GET /user (validates fine-grained PAT +
|
|
// implicit metadata:read).
|
|
// - Gitea (Wave I, R-005): GET /api/v1/version + GET /api/v1/user/repos
|
|
// (≥1.22 read:repository) OR GET /api/v1/repos/search (<1.22 validity).
|
|
// The mock returns valid envelopes for these validation endpoints.
|
|
const realFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
// Proxmox.
|
|
if (url.includes("/api2/json/version")) {
|
|
return new Response(JSON.stringify({ data: { version: "8.2.4", release: "bookworm" } }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
if (url.includes("/api2/json/nodes")) {
|
|
return new Response(JSON.stringify({ data: [{ node: "pve1", status: "online" }] }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
// GitHub — GET /user (api.github.com or a GHES host).
|
|
if (/^https:\/\/[^/]+\/user(?:\?|$)/.test(url) && !url.includes("/api/v1/")) {
|
|
return new Response(JSON.stringify({ id: 1, login: "octo", type: "User" }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
// Gitea — GET /api/v1/version.
|
|
if (url.includes("/api/v1/version")) {
|
|
return new Response(JSON.stringify({ version: "1.22.0", revision: "abc" }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
// Gitea — GET /api/v1/user/repos (≥1.22 read:repository validation).
|
|
if (url.includes("/api/v1/user/repos")) {
|
|
return new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
// Gitea — GET /api/v1/repos/search (<1.22 token-validity check).
|
|
if (url.includes("/api/v1/repos/search")) {
|
|
return new Response(JSON.stringify({ ok: true, data: [] }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
return realFetch(input as RequestInfo | URL, init);
|
|
}) as typeof globalThis.fetch;
|
|
// Use the control-plane's getDb() so the test + route handlers share the
|
|
// same PGlite instance (getDb bootstraps + runs migrations 0001..0003).
|
|
db = await getDb();
|
|
admin = await seedTenant("org_mcp_routes", "admin@mcp-routes.test");
|
|
// Wire the MCP runtime to share this DB + the stub adapters + a local secrets provider.
|
|
const { InMemoryRateLimiter, StreamManager, InProcessTransport, defaultStubs } = await import(
|
|
"@coreci/mcp"
|
|
);
|
|
const { LocalEncryptedProvider } = await import("@coreci/secrets");
|
|
const streamManager = new StreamManager({ notOpenedTimeoutMs: 5000, maxLifetimeMs: 10000 });
|
|
const transport = new InProcessTransport();
|
|
for (const stub of defaultStubs()) await transport.register(stub);
|
|
setMcpRuntime({
|
|
db,
|
|
secrets: new LocalEncryptedProvider({ masterKey: MASTER_KEY }),
|
|
rateLimiter: new InMemoryRateLimiter(),
|
|
streamManager,
|
|
transport,
|
|
});
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Wipe mcp_adapters between tests (disable RLS to mutate, re-enable).
|
|
await db.query("ALTER TABLE mcp_adapters DISABLE ROW LEVEL SECURITY");
|
|
await db.query("DELETE FROM mcp_adapters");
|
|
await db.query("ALTER TABLE mcp_adapters ENABLE ROW LEVEL SECURITY");
|
|
// Reset the stream manager between tests so contexts don't leak.
|
|
const { streamManager } = await getMcpRuntime();
|
|
streamManager.reset();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
const { streamManager } = await getMcpRuntime();
|
|
streamManager.reset();
|
|
});
|
|
|
|
describe("GET /api/mcp/tools — closed 9-tool set (REQ-015)", () => {
|
|
it("returns exactly 9 tools with name + description + inputSchema", async () => {
|
|
const res = await getTools(authedReq("/api/mcp/tools", admin!.token));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { tools: { name: string; description: string; inputSchema: unknown }[] };
|
|
expect(json.tools).toHaveLength(9);
|
|
for (const t of json.tools) {
|
|
expect(typeof t.name).toBe("string");
|
|
expect(typeof t.description).toBe("string");
|
|
expect(t.inputSchema).toBeTypeOf("object");
|
|
}
|
|
expect(json.tools.map((t) => t.name).sort()).toContain("proxmox.list_vms");
|
|
});
|
|
|
|
it("returns 401 without a session cookie", async () => {
|
|
const res = await getTools(new NextRequest(new URL("/api/mcp/tools", "http://localhost:3000")));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/mcp/adapter — configure an adapter (INV-3, adapter.configured audit)", () => {
|
|
it("persists the adapter + audit event; returns adapterId", async () => {
|
|
const res = await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "proxmox",
|
|
targetId: "pve1",
|
|
config: { host: "https://pve1:8006" },
|
|
secret: "PVEAPIToken=root@pam!t=uuid",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { ok: boolean; adapterId: string };
|
|
expect(json.ok).toBe(true);
|
|
expect(json.adapterId).toBeTypeOf("string");
|
|
|
|
// Audit event appended (adapter.configured).
|
|
const audit = await withTenant(admin!.tenantId, async (c: ScopedClient) => {
|
|
const r = await c.query<{ event_type: string }>(
|
|
`SELECT event_type FROM audit_log WHERE tenant_id = $1 ORDER BY id DESC LIMIT 1`,
|
|
[admin!.tenantId],
|
|
);
|
|
return r.rows[0]?.event_type;
|
|
});
|
|
expect(audit).toBe("adapter.configured");
|
|
});
|
|
|
|
it("rejects an invalid adapterType with 400", async () => {
|
|
const res = await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "bogus",
|
|
targetId: "x",
|
|
secret: "s",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects a missing secret with 400", async () => {
|
|
const res = await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "github",
|
|
targetId: "gh1",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects a Proxmox config without config.host with 400", async () => {
|
|
const res = await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "proxmox",
|
|
targetId: "pve1",
|
|
config: {},
|
|
secret: "PVEAPIToken=root@pam!t=uuid",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 422 (role_violation) when the Proxmox token fails GET /version (REQ-025)", async () => {
|
|
// Override the fetch mock to return 401 for /version on this host.
|
|
const realFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/api2/json/version")) {
|
|
return new Response("nope", { status: 401 });
|
|
}
|
|
return new Response("nope", { status: 404 });
|
|
}) as typeof globalThis.fetch;
|
|
try {
|
|
const res = await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "proxmox",
|
|
targetId: "pve-bad",
|
|
config: { host: "pve-bad.example.com" },
|
|
secret: "PVEAPIToken=root@pam!bad=uuid",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
const json = (await res.json()) as { error: string; code: string };
|
|
expect(json.error).toBe("role_violation");
|
|
expect(json.code).toBe("invalid_token");
|
|
} finally {
|
|
globalThis.fetch = realFetch;
|
|
}
|
|
});
|
|
|
|
it("returns 422 (role_violation, insufficient_role) when GET /nodes is 403 (no Sys.Audit)", async () => {
|
|
const realFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/api2/json/version")) {
|
|
return new Response(JSON.stringify({ data: { version: "8.0" } }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
if (url.includes("/api2/json/nodes")) {
|
|
return new Response("forbidden", { status: 403 });
|
|
}
|
|
return new Response("nope", { status: 404 });
|
|
}) as typeof globalThis.fetch;
|
|
try {
|
|
const res = await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "proxmox",
|
|
targetId: "pve-noaudit",
|
|
config: { host: "pve-noaudit.example.com" },
|
|
secret: "PVEAPIToken=root@pam!noaudit=uuid",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
const json = (await res.json()) as { error: string; code: string };
|
|
expect(json.code).toBe("insufficient_role");
|
|
} finally {
|
|
globalThis.fetch = realFetch;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("GET /api/mcp/adapter — list adapters (RLS-scoped)", () => {
|
|
it("returns the tenant's adapters", async () => {
|
|
await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "github",
|
|
targetId: "gh1",
|
|
config: {},
|
|
secret: "github_pat_x",
|
|
}),
|
|
);
|
|
const res = await getAdapter(authedReq("/api/mcp/adapter", admin!.token));
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { adapters: { adapterType: string; targetId: string }[] };
|
|
expect(json.adapters).toHaveLength(1);
|
|
expect(json.adapters[0]?.targetId).toBe("gh1");
|
|
});
|
|
});
|
|
|
|
describe("POST /api/mcp/invoke — correlation context (REQ-016/017/024)", () => {
|
|
it("returns {correlationId, streamUrl} on a happy-path invoke", async () => {
|
|
await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "proxmox",
|
|
targetId: "pve1",
|
|
config: { host: "pve1.example.com" },
|
|
secret: "s",
|
|
}),
|
|
);
|
|
const res = await postInvoke(
|
|
authedReq("/api/mcp/invoke", admin!.token, "POST", {
|
|
toolName: "proxmox.list_vms",
|
|
args: { node: "pve1" },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { correlationId: string; streamUrl: string };
|
|
expect(json.correlationId).toHaveLength(26);
|
|
expect(json.streamUrl).toBe(`/api/mcp/stream/${json.correlationId}`);
|
|
});
|
|
|
|
it("returns 400 on invalid args (Edge 4) — missing required 'node'", async () => {
|
|
const res = await postInvoke(
|
|
authedReq("/api/mcp/invoke", admin!.token, "POST", {
|
|
toolName: "proxmox.list_vms",
|
|
args: {},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error: string };
|
|
expect(json.error).toBe("invalid_args");
|
|
});
|
|
|
|
it("returns 404 adapter_not_found when no adapter of the type exists", async () => {
|
|
const res = await postInvoke(
|
|
authedReq("/api/mcp/invoke", admin!.token, "POST", {
|
|
toolName: "gitea.list_repos",
|
|
args: {},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(404);
|
|
const json = (await res.json()) as { error: string };
|
|
expect(json.error).toBe("adapter_not_found");
|
|
});
|
|
|
|
it("returns 400 target_required (Edge 3) when ≥2 same-type and no targetId", async () => {
|
|
await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "proxmox",
|
|
targetId: "a",
|
|
config: { host: "pve-a.example.com" },
|
|
secret: "s",
|
|
}),
|
|
);
|
|
await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "proxmox",
|
|
targetId: "b",
|
|
config: { host: "pve-b.example.com" },
|
|
secret: "s",
|
|
}),
|
|
);
|
|
const res = await postInvoke(
|
|
authedReq("/api/mcp/invoke", admin!.token, "POST", {
|
|
toolName: "proxmox.list_vms",
|
|
args: { node: "pve1" },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error: string; detail?: { availableTargets?: string[] } | unknown };
|
|
expect(json.error).toBe("target_required");
|
|
});
|
|
});
|
|
|
|
describe("PATCH/DELETE /api/mcp/adapter/[id]", () => {
|
|
it("PATCH updates config; DELETE removes the row", async () => {
|
|
const postRes = await postAdapter(
|
|
authedReq("/api/mcp/adapter", admin!.token, "POST", {
|
|
adapterType: "github",
|
|
targetId: "gh-pd",
|
|
config: { host: "a" },
|
|
secret: "github_pat_test_secret",
|
|
}),
|
|
);
|
|
const { adapterId } = (await postRes.json()) as { adapterId: string };
|
|
|
|
const patchRes = await patchAdapter(
|
|
authedReq(`/api/mcp/adapter/${adapterId}`, admin!.token, "PATCH", {
|
|
config: { host: "b" },
|
|
}),
|
|
{ params: Promise.resolve({ id: adapterId }) },
|
|
);
|
|
expect(patchRes.status).toBe(200);
|
|
|
|
const delRes = await deleteAdapter(
|
|
authedReq(`/api/mcp/adapter/${adapterId}`, admin!.token, "DELETE"),
|
|
{ params: Promise.resolve({ id: adapterId }) },
|
|
);
|
|
expect(delRes.status).toBe(200);
|
|
|
|
// Gone: GET /api/mcp/adapter lists 0.
|
|
const listRes = await getAdapter(authedReq("/api/mcp/adapter", admin!.token));
|
|
const listJson = (await listRes.json()) as { adapters: unknown[] };
|
|
expect(listJson.adapters).toHaveLength(0);
|
|
});
|
|
|
|
it("DELETE returns 404 for a cross-tenant id (RLS)", async () => {
|
|
const res = await deleteAdapter(
|
|
authedReq("/api/mcp/adapter/00000000-0000-0000-0000-000000000099", admin!.token, "DELETE"),
|
|
{ params: Promise.resolve({ id: "00000000-0000-0000-0000-000000000099" }) },
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
}); |