SysDeck/klanker-gate/tests/integration/mcp_plugins_test.ts

422 lines
13 KiB
TypeScript
Executable File

// MCP + plugin runtime through the real gateway: client CRUD + sync via the
// admin API, MCP tool execution inside the chat loop (with side-effect
// confirmation), and plugin pre/post/stream-complete hooks.
import { assert, assertEquals } from "@std/assert";
import { createHandler } from "../../apps/gateway/main.ts";
import { type AppContext, VERSION } from "../../apps/gateway/context.ts";
import { ConfigService } from "../../packages/config/src/service.ts";
import { ProviderManager } from "../../packages/providers/src/mod.ts";
import { Metrics } from "../../packages/telemetry/src/metrics.ts";
import { LogBus } from "../../packages/telemetry/src/logbus.ts";
import { VirtualKeyManager } from "../../packages/governance/src/virtual_keys.ts";
import {
MCPClientConfigSchema,
redactMCPClientConfig,
} from "../../packages/mcp/src/client.ts";
import { MCPRegistry } from "../../packages/mcp/src/registry.ts";
import { PluginManager } from "../../packages/plugins/src/lifecycle.ts";
import {
jsonResponse,
MockProvider,
openAIChatBody,
openAIStreamFrames,
} from "../../packages/testing/src/mod.ts";
const base = "http://gateway.test";
/** Real HTTP JSON-RPC MCP server for integration coverage. */
function startMCPServer(
tools: Array<Record<string, unknown>>,
onCall: (name: string, args: unknown) => string,
) {
const calls: Array<{ name: string; args: unknown }> = [];
const server = Deno.serve({ port: 0, onListen: () => {} }, async (req) => {
const body = await req.json();
let result: unknown = {};
if (body.method === "initialize") {
result = { protocolVersion: "2025-06-18" };
} else if (body.method === "tools/list") {
result = { tools };
} else if (body.method === "tools/call") {
calls.push({ name: body.params.name, args: body.params.arguments });
result = {
content: [{
type: "text",
text: onCall(body.params.name, body.params.arguments),
}],
};
}
return new Response(
JSON.stringify({ jsonrpc: "2.0", id: body.id, result }),
{ headers: { "Content-Type": "application/json" } },
);
});
return {
url: `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`,
calls,
close: () => server.shutdown(),
};
}
async function makeContext(
providerUrl: string,
mcp = new MCPRegistry(),
): Promise<AppContext> {
return {
providers: new ProviderManager([{
id: "openai",
type: "openai",
apiKey: "k",
baseUrl: providerUrl,
enabled: true,
models: ["m1"],
priority: 0,
retry: { maxRetries: 0 },
}], "openai"),
metrics: new Metrics(),
logBus: new LogBus(),
virtualKeys: new VirtualKeyManager(),
mcp,
plugins: new PluginManager(),
toolExecutor: mcp.executor(),
version: VERSION,
config: await ConfigService.open(":memory:"),
};
}
function weatherToolCall() {
return openAIChatBody("", {
choices: [{
index: 0,
message: {
role: "assistant",
content: null,
tool_calls: [{
id: "call_1",
type: "function",
function: { name: "get_weather", arguments: '{"city":"Oslo"}' },
}],
},
finish_reason: "tool_calls",
}],
});
}
Deno.test("MCP: admin CRUD, sync, catalog, and tool execution in the chat loop", async (t) => {
const mcpServer = startMCPServer(
[{
name: "get_weather",
description: "Weather lookup",
annotations: { readOnlyHint: true },
}],
() => JSON.stringify({ temp: 21 }),
);
const provider = new MockProvider((_call, index) =>
jsonResponse(
index === 0 ? weatherToolCall() : openAIChatBody("21 degrees in Oslo"),
)
);
const ctx = await makeContext(provider.url);
const handler = createHandler(ctx);
try {
await t.step("register the MCP client via the admin API", async () => {
const res = await handler(
new Request(`${base}/api/mcp/clients`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "weather",
url: `${mcpServer.url}/rpc`,
transport: "streamable-http",
enabled: true,
}),
}),
);
assertEquals(res.status, 201);
assertEquals((await res.json()).toolCount, 0); // not synced yet
assertEquals((await ctx.config!.listMCPClients()).length, 1);
});
await t.step("sync pulls the tool catalog", async () => {
const res = await handler(
new Request(`${base}/api/mcp/clients/weather/sync`, {
method: "POST",
}),
);
assertEquals(await res.json(), { id: "weather", tools: 1 });
const catalog = await (await handler(
new Request(`${base}/api/mcp/tools`),
)).json();
assertEquals(catalog.tools[0].name, "get_weather");
assertEquals(catalog.tools[0].clientId, "weather");
});
await t.step(
"chat executes the read-only MCP tool without confirmation",
async () => {
const res = await handler(
new Request(`${base}/v1/chat/completions`, {
method: "POST",
body: JSON.stringify({
model: "m1",
messages: [{ role: "user", content: "weather in Oslo?" }],
tools: [{
type: "function",
function: { name: "get_weather", parameters: {} },
}],
}),
}),
);
assertEquals(res.status, 200);
const body = await res.json();
assertEquals(body.choices[0].message.content, "21 degrees in Oslo");
assertEquals(mcpServer.calls, [
{ name: "get_weather", args: { city: "Oslo" } },
]);
// the tool result was fed back upstream
const second = provider.calls[1].body as {
messages: Array<{ role: string; content?: string }>;
};
assertEquals(second.messages.at(-1)?.role, "tool");
assertEquals(second.messages.at(-1)?.content, '{"temp":21}');
},
);
await t.step("delete removes the client and its tools", async () => {
const res = await handler(
new Request(`${base}/api/mcp/clients/weather`, { method: "DELETE" }),
);
assertEquals(res.status, 204);
const catalog = await (await handler(
new Request(`${base}/api/mcp/tools`),
)).json();
assertEquals(catalog.tools, []);
assertEquals(await ctx.config!.listMCPClients(), []);
});
} finally {
ctx.config!.close();
await provider.close();
await mcpServer.close();
}
});
Deno.test("MCP: control-plane views redact connection secrets", async () => {
const provider = new MockProvider(() => jsonResponse(openAIChatBody("ok")));
const ctx = await makeContext(provider.url);
const handler = createHandler(ctx);
const secret = "mcp-test-secret-not-for-control-plane";
try {
const create = await handler(
new Request(`${base}/api/mcp/clients`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "private-client",
url: `http://operator:${secret}@mcp.example.test/rpc`,
transport: "streamable-http",
headers: { Authorization: `Bearer ${secret}`, "X-Workspace": "ops" },
}),
}),
);
assertEquals(create.status, 201);
const created = await create.json() as Record<string, unknown>;
assertEquals(created.headerNames, ["Authorization", "X-Workspace"]);
assertEquals(created.hasUrlCredentials, true);
assertEquals(created.hasCommand, false);
assert(!JSON.stringify(created).includes(secret));
assert(!("headers" in created));
const listed = await handler(new Request(`${base}/api/mcp/clients`));
const listedText = await listed.text();
assert(!listedText.includes(secret));
assertEquals(JSON.parse(listedText).clients[0].headerNames, [
"Authorization",
"X-Workspace",
]);
const update = await handler(
new Request(`${base}/api/mcp/clients/private-client`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: false }),
}),
);
assertEquals(update.status, 200);
assert(!JSON.stringify(await update.json()).includes(secret));
assertEquals(
ctx.mcp.get("private-client")!.config.headers?.Authorization,
`Bearer ${secret}`,
);
} finally {
ctx.config!.close();
await provider.close();
}
});
Deno.test("MCP: public config redacts headers, commands, and URL user-info", () => {
const secret = "mcp-test-secret-not-for-public-config";
const view = redactMCPClientConfig(MCPClientConfigSchema.parse({
id: "private-stdio",
url: `http://operator:${secret}@mcp.example.test/rpc`,
transport: "stdio",
command: ["mcp-server", `--token=${secret}`],
headers: { Authorization: `Bearer ${secret}` },
}));
assertEquals(view.headerNames, ["Authorization"]);
assertEquals(view.hasCommand, true);
assertEquals(view.hasUrlCredentials, true);
assert(!("headers" in view));
assert(!("command" in view));
assert(!JSON.stringify(view).includes(secret));
});
Deno.test("MCP: sync errors do not reflect upstream response text", async () => {
const secret = "mcp-test-secret-not-from-upstream";
const provider = new MockProvider(() => jsonResponse(openAIChatBody("ok")));
const mcp = new MCPRegistry(
[],
(() =>
Promise.resolve(new Response(secret, { status: 401 }))) as typeof fetch,
);
const ctx = await makeContext(provider.url, mcp);
const handler = createHandler(ctx);
mcp.upsert({
id: "echoing-upstream",
url: "http://mcp.example.test/rpc",
transport: "streamable-http",
});
try {
const response = await handler(
new Request(`${base}/api/mcp/clients/echoing-upstream/sync`, {
method: "POST",
}),
);
assertEquals(response.status, 502);
const body = await response.text();
assert(!body.includes(secret));
assert(body.includes("MCP sync failed."));
} finally {
ctx.config!.close();
await provider.close();
}
});
Deno.test("MCP: side-effect tools still require explicit confirmation", async () => {
const mcpServer = startMCPServer(
[{ name: "get_weather", description: "no readOnlyHint -> side effect" }],
() => "done",
);
const provider = new MockProvider(() => jsonResponse(weatherToolCall()));
const ctx = await makeContext(provider.url);
ctx.mcp.upsert({
id: "weather",
url: `${mcpServer.url}/rpc`,
transport: "streamable-http",
enabled: true,
});
await ctx.mcp.syncAll();
const handler = createHandler(ctx);
try {
const res = await handler(
new Request(`${base}/v1/chat/completions`, {
method: "POST",
body: JSON.stringify({
model: "m1",
messages: [{ role: "user", content: "weather?" }],
tools: [{
type: "function",
function: { name: "get_weather", parameters: {} },
}],
}),
}),
);
assertEquals(res.status, 403);
assertEquals((await res.json()).error.type, "side_effect_denied");
assertEquals(mcpServer.calls.length, 0);
} finally {
ctx.config!.close();
await provider.close();
await mcpServer.close();
}
});
Deno.test("plugins: pre and post hooks run around non-streaming chat", async () => {
const provider = new MockProvider(() =>
jsonResponse(openAIChatBody("plain answer"))
);
const ctx = await makeContext(provider.url);
ctx.plugins.register({
name: "tagger",
onPreRequest: (req) => Promise.resolve({ ...req, user: "plugin-user" }),
onPostRequest: (res) =>
Promise.resolve({ ...res, system_fingerprint: "post-hooked" }),
});
const handler = createHandler(ctx);
try {
const res = await handler(
new Request(`${base}/v1/chat/completions`, {
method: "POST",
body: JSON.stringify({
model: "m1",
messages: [{ role: "user", content: "hi" }],
}),
}),
);
const body = await res.json();
assertEquals(body.system_fingerprint, "post-hooked");
// pre-hook mutation reached the provider
const sent = provider.calls[0].body as { user?: string };
assertEquals(sent.user, "plugin-user");
// plugin listing exposed for the UI
const plugins = await (await handler(
new Request(`${base}/api/plugins`),
)).json();
assertEquals(plugins.plugins, ["tagger"]);
} finally {
ctx.config!.close();
await provider.close();
}
});
Deno.test("plugins: stream-completion hook receives the full assistant text", async () => {
const provider = new MockProvider(() =>
new Response(openAIStreamFrames(["Hel", "lo!"]).join(""), {
headers: { "Content-Type": "text/event-stream" },
})
);
const ctx = await makeContext(provider.url);
const completions: string[] = [];
ctx.plugins.register({
name: "stream-capture",
onStreamComplete: (text) => {
completions.push(text);
return Promise.resolve();
},
});
const handler = createHandler(ctx);
try {
const res = await handler(
new Request(`${base}/v1/chat/completions`, {
method: "POST",
body: JSON.stringify({
model: "m1",
messages: [{ role: "user", content: "hi" }],
stream: true,
}),
}),
);
const text = await res.text(); // drain the stream fully
assert(text.trimEnd().endsWith("data: [DONE]"));
assertEquals(completions, ["Hello!"]);
} finally {
ctx.config!.close();
await provider.close();
}
});