feat(agent): add external MCP client support
Reuse OpenCode 2 HTTP/stdio clients with per-session read-only grants, native approval and sanitized owner-scoped MCP status. Narrow the environment inherited by trusted local MCP processes. Validated in an isolated checkout with bun run check and 20 focused tests.
This commit is contained in:
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
import { Plugin, Rpc } from "../../.opencode/node_modules/@opencode/plugin/dist/promise/index.js";
|
||||
import { z } from "../../.opencode/node_modules/zod/index.js";
|
||||
|
||||
// Loaded only by the opt-in isolated smoke script, never by the Agent service.
|
||||
export default Plugin.define({
|
||||
id: "tjwater.mcp-fixture",
|
||||
async setup(ctx) {
|
||||
// Materialize the native session toolbox, then stop before any model call.
|
||||
await ctx.session.hook("context", () => { throw new Error("MCP fixture stops before model request"); });
|
||||
const evaluated: string[] = [];
|
||||
await ctx.permission.hook("evaluate", (event) => { evaluated.push(event.action); });
|
||||
await ctx.rpc.register(Rpc.define({
|
||||
id: "tjwater.mcp-fixture", events: {}, methods: {
|
||||
call: { input: z.object({ session_id: z.string(), tool: z.enum(["mcp_stdio_echo", "mcp_http_write"]), value: z.string() }), output: z.object({ content: z.unknown(), evaluated: z.array(z.string()), error: z.string().optional() }) },
|
||||
},
|
||||
}), {
|
||||
call: async (input, context) => {
|
||||
const tool = (await ctx.tool.list()).find((tool) => tool.id === input.tool);
|
||||
if (!tool) throw new Error("fixture tool missing");
|
||||
try {
|
||||
const result = await tool.execute({ value: input.value }, { sessionID: input.session_id as never, id: "call-mcp-fixture" as never, messageID: "msg-mcp-fixture" as never, agent: "instruction" as never, signal: context.signal, progress: async () => {} });
|
||||
return { content: result.content ?? [], evaluated };
|
||||
} catch (error) { return { content: [], evaluated, error: String(error) }; }
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
// A minimal legacy stdio fixture. No third-party SDK or external service.
|
||||
for await (const line of createInterface({ input: process.stdin })) {
|
||||
const request = JSON.parse(line);
|
||||
if (request.id === undefined) continue;
|
||||
let result: unknown;
|
||||
if (request.method === "initialize") result = { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "tjwater-mcp-fixture", version: "1" } };
|
||||
else if (request.method === "tools/list") result = { tools: [{ name: "echo", description: "Read-only fixture echo", inputSchema: { type: "object", properties: { value: { type: "string" } }, required: ["value"] }, annotations: { readOnlyHint: true } }] };
|
||||
else if (request.method === "tools/call") result = { content: [{ type: "text", text: JSON.stringify({ value: request.params.arguments.value, internal_token_empty: !process.env.AGENT_INTERNAL_TOKEN, model_key_empty: !process.env.DEEPSEEK_API_KEY, own_token_present: process.env.MCP_FIXTURE_TOKEN === "MCP_ONLY" }) }] };
|
||||
else if (request.method === "ping") result = {};
|
||||
else {
|
||||
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } })}\n`);
|
||||
continue;
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n`);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ describe("OpenCode 2 business tool registration", () => {
|
||||
const storage = new Map<string, unknown>();
|
||||
let rpcList: (() => Promise<unknown>) | undefined;
|
||||
await plugin.setup({
|
||||
tool: { transform: async (fn: (editor: unknown) => void) => fn({ add: (tool: any) => tools.set(tool.name, tool), remove: (name: string) => tools.delete(name), update: () => {} }), list: async () => Array.from(tools, ([id, tool]) => ({ ...tool, id })) },
|
||||
tool: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => Array.from(tools, ([id, tool]) => ({ ...tool, id })), add: (tool: any) => tools.set(tool.name, tool), remove: (name: string) => tools.delete(name), update: () => {} }), list: async () => Array.from(tools, ([id, tool]) => ({ ...tool, id })) },
|
||||
mcp: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [] }), list: async () => ({ data: [] }) },
|
||||
shell: { hook: async () => {} },
|
||||
permission: { hook: async () => {} },
|
||||
session: { hook: async () => {} },
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import express from "express";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
|
||||
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
import { mcpRuntimeReportSchema } from "../../src/runtime/mcp-config.js";
|
||||
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
|
||||
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
// Opt-in: bun run tests/integration/mcp-smoke.ts. No model request is made.
|
||||
const root = await mkdtemp(join(tmpdir(), "tjwater-mcp-smoke-"));
|
||||
const pluginDirectory = join(root, "fixture-plugin");
|
||||
await mkdir(pluginDirectory);
|
||||
await writeFile(join(pluginDirectory, "package.json"), JSON.stringify({ name: "tjwater-mcp-fixture", type: "module", exports: "./index.ts" }));
|
||||
await writeFile(join(pluginDirectory, "index.ts"), `export { default } from ${JSON.stringify(resolve("tests/fixtures/mcp-call-plugin.ts"))};\n`);
|
||||
const app = express(); app.use(express.json());
|
||||
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => ["mcp_stdio_echo"] } });
|
||||
const wire: { method: string; version?: string; header?: string }[] = [];
|
||||
app.post("/mcp", (req, res) => {
|
||||
const request = req.body;
|
||||
wire.push({ method: request.method, version: request.params?._meta?.["io.modelcontextprotocol/protocolVersion"], header: req.header("Mcp-Method") });
|
||||
const common = { resultType: "complete", ttlMs: 0, cacheScope: "private", _meta: { "io.modelcontextprotocol/serverInfo": { name: "tjwater-modern-fixture", version: "1" } } };
|
||||
let result: unknown;
|
||||
if (request.method === "server/discover") result = { ...common, supportedVersions: ["2026-07-28"], capabilities: { tools: {} } };
|
||||
else if (request.method === "tools/list") result = { ...common, tools: [{ name: "write", description: "Unapproved test tool", inputSchema: { type: "object", properties: { value: { type: "string" } } }, annotations: { readOnlyHint: true } }] };
|
||||
else { res.status(400).json({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }); return; }
|
||||
res.json({ jsonrpc: "2.0", id: request.id, result });
|
||||
});
|
||||
const bridge = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => bridge.once("listening", resolve));
|
||||
const baseUrl = `http://127.0.0.1:${(bridge.address() as { port: number }).port}`;
|
||||
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const source = JSON.parse(await readFile("opencode.json", "utf8"));
|
||||
const nativeConfig = serviceOpencodeConfig({ ...source, plugins: [pluginDirectory], mcp: {
|
||||
servers: {
|
||||
mcp_http: { type: "remote", url: `${baseUrl}/mcp`, oauth: false },
|
||||
mcp_stdio: { type: "local", command: [process.execPath, resolve("tests/fixtures/mcp-server.ts")], environment: { MCP_FIXTURE_TOKEN: "MCP_ONLY" } },
|
||||
},
|
||||
}, permissions: [...source.permissions, { action: "mcp_stdio_echo", resource: "*", effect: "allow" }] });
|
||||
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000, config: nativeConfig, env: {
|
||||
AGENT_INTERNAL_TOKEN: "MCP_TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: baseUrl, TJWATER_AGENT_WORKSPACE_ROOT: join(root, "workspaces"),
|
||||
DEEPSEEK_API_KEY: "TEST_ONLY_NEVER_USED", XDG_DATA_HOME: join(root, "data"), XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state"),
|
||||
} });
|
||||
const session = await native.client.session.create({ location: { directory: process.cwd() }, agent: "instruction" });
|
||||
sessionId = session.id;
|
||||
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "test", projectKey: "test", traceId: "test", purpose: "task_execution" });
|
||||
await native.client.session.prompt({ sessionID: sessionId, text: "Materialize fixture toolbox only; the fixture context hook stops the model request." });
|
||||
await native.client.session.wait({ sessionID: sessionId }, { signal: AbortSignal.timeout(20_000) });
|
||||
const readReport = async () => mcpRuntimeReportSchema.parse((await native!.client.rpc.call({ rpcID: "tjwater.tools", method: "mcp", input: {}, location: session.location }, { signal: AbortSignal.timeout(10_000) })).output);
|
||||
let report = await readReport();
|
||||
const deadline = Date.now() + 5000;
|
||||
while (report.servers.flatMap((server) => server.tools).length < 2 && Date.now() < deadline) {
|
||||
// Native late connection updates debounce catalog publication by 100 ms.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
report = await readReport();
|
||||
}
|
||||
assert.deepEqual(report.configuration_errors, []);
|
||||
assert.deepEqual(report.servers.map(({ name, status, configured_protocol }) => ({ name, status, configured_protocol })), [
|
||||
{ name: "mcp_http", status: "connected", configured_protocol: "auto" },
|
||||
{ name: "mcp_stdio", status: "connected", configured_protocol: "legacy" },
|
||||
]);
|
||||
assert(wire.some((request) => request.method === "tools/list" && request.version === "2026-07-28" && request.header === "tools/list"));
|
||||
assert.deepEqual(report.servers.flatMap((server) => server.tools).sort(), ["mcp_http_write", "mcp_stdio_echo"]);
|
||||
const result = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: { session_id: sessionId, tool: "mcp_stdio_echo", value: "mcp-ok" }, location: session.location }, { signal: AbortSignal.timeout(10_000) });
|
||||
const output = JSON.stringify(result.output);
|
||||
assert.equal((result.output as { error?: string }).error, undefined);
|
||||
assert((result.output as { evaluated: string[] }).evaluated.includes("mcp_stdio_echo"), "native MCP execution must evaluate permission");
|
||||
for (const expected of ["mcp-ok", "internal_token_empty", "model_key_empty", "own_token_present"]) assert(output.includes(expected));
|
||||
assert(!output.includes("false"), "stdio must not inherit service credentials");
|
||||
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "test", projectKey: "test", traceId: "test", purpose: "ordinary_chat" });
|
||||
const writeAttempt = native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: { session_id: sessionId, tool: "mcp_http_write", value: "must-not-run" }, location: session.location }, { signal: AbortSignal.timeout(5000) });
|
||||
// Confirm a real native permission request, then reject before the wire call.
|
||||
let pending = await native.client.permission.list({ sessionID: sessionId });
|
||||
const approvalDeadline = Date.now() + 2000;
|
||||
while (!pending.some((request) => request.action === "mcp_http_write") && Date.now() < approvalDeadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
pending = await native.client.permission.list({ sessionID: sessionId });
|
||||
}
|
||||
const request = pending.find((request) => request.action === "mcp_http_write");
|
||||
assert(request, "unapproved MCP tool must create a native permission request");
|
||||
await native.client.permission.reply({ sessionID: sessionId, requestID: request.id, decision: "reject" });
|
||||
const writeResult = await writeAttempt;
|
||||
assert((writeResult.output as { error?: string }).error);
|
||||
assert(!wire.some((request) => request.method === "tools/call"), "rejected HTTP tool must not execute");
|
||||
console.log("MCP smoke passed: modern HTTP discovery, legacy stdio call, native approval/rejection and credential isolation; no model request.");
|
||||
} finally {
|
||||
if (sessionId) {
|
||||
removeRuntimeSessionContext(sessionId);
|
||||
await native?.client.session.remove({ sessionID: sessionId }).catch(() => {});
|
||||
}
|
||||
native?.close();
|
||||
if (native) await native.exited;
|
||||
await new Promise<void>((resolve) => bridge.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import type { Plugin } from "../../.opencode/node_modules/@opencode/plugin/dist/promise/index.js";
|
||||
import { registerMcpPolicy } from "../../.opencode/mcp-policy.js";
|
||||
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
|
||||
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
type Context = Parameters<Plugin.Plugin["setup"]>[0];
|
||||
type Evaluation = Parameters<Parameters<Context["permission"]["hook"]>[1]>[0];
|
||||
type Tool = Awaited<ReturnType<Context["tool"]["list"]>>[number];
|
||||
|
||||
describe("MCP session enforcement", () => {
|
||||
it("applies grants and role restrictions to direct and Code Mode executors and saved approvals", async () => {
|
||||
const app = express(); app.use(express.json());
|
||||
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => ["mcp_fixture_read"] } });
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const previous = [process.env.AGENT_INTERNAL_TOKEN, process.env.TJWATER_AGENT_INTERNAL_BASE_URL];
|
||||
process.env.AGENT_INTERNAL_TOKEN = "MCP_TEST_ONLY";
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
const sessionID = "mcp-policy-test";
|
||||
const baseContext = { sessionId: sessionID, clientSessionId: sessionID, actorKey: "owner", projectKey: "project", traceId: "test" };
|
||||
const calls: string[] = [];
|
||||
const tools = new Map<string, Tool>([true, false].flatMap((codemode) => {
|
||||
const id = codemode ? "mcp_fixture_read" : "mcp_fixture_write";
|
||||
return [[id, { id, name: id, description: "test", input: {}, options: { codemode }, execute: async (_input: unknown, context: { signal: AbortSignal }) => {
|
||||
context.signal.throwIfAborted(); calls.push(id); return { content: "ok" };
|
||||
} } as Tool]];
|
||||
}));
|
||||
let evaluate: ((event: Evaluation) => Promise<void>) | undefined;
|
||||
let filterContext: ((event: { sessionID: string; tools: Record<string, unknown>; system: unknown[] }) => Promise<void>) | undefined;
|
||||
const report = await registerMcpPolicy({
|
||||
mcp: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [] }), list: async () => ({ data: [] }) },
|
||||
tool: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [...tools.values()], update: (id: string, fn: (tool: Tool) => void) => fn(tools.get(id)!) }), list: async () => [...tools.values()] },
|
||||
permission: { hook: async (_name: string, fn: typeof evaluate) => { evaluate = fn; } },
|
||||
session: { hook: async (_name: string, fn: typeof filterContext) => { filterContext = fn; } },
|
||||
} as never);
|
||||
const callContext = { sessionID, signal: new AbortController().signal } as Parameters<Tool["execute"]>[1];
|
||||
const decision = async (action: string, effect: "allow" | "ask" | "deny" = "allow") => {
|
||||
const event: Evaluation = { sessionID: sessionID as Evaluation["sessionID"], action, effect, resources: ["*"] };
|
||||
await evaluate!(event); return event.effect;
|
||||
};
|
||||
try {
|
||||
setRuntimeSessionContext({ ...baseContext, purpose: "ordinary_chat" });
|
||||
expect(await decision("mcp_fixture_read")).toBe("allow");
|
||||
expect(await decision("mcp_fixture_write")).toBe("ask");
|
||||
expect(await decision("opencode_read_mcp_resource")).toBe("ask");
|
||||
expect(await decision("mcp_fixture_read", "deny")).toBe("deny");
|
||||
setRuntimeSessionContext({ ...baseContext, purpose: "task_execution" });
|
||||
expect(await tools.get("mcp_fixture_read")!.execute({}, callContext)).toEqual({ content: "ok" });
|
||||
await expect(tools.get("mcp_fixture_write")!.execute({}, callContext)).rejects.toThrow("not available");
|
||||
expect(await decision("mcp_fixture_write")).toBe("deny");
|
||||
expect(await decision("opencode_read_mcp_resource")).toBe("deny");
|
||||
const event = { sessionID, tools: { mcp_fixture_read: {}, mcp_fixture_write: {}, shell: {} }, system: [] };
|
||||
await filterContext!(event);
|
||||
expect(Object.keys(event.tools)).toEqual(["mcp_fixture_read", "shell"]);
|
||||
setRuntimeSessionContext({ ...baseContext, learningMode: "review" });
|
||||
await expect(tools.get("mcp_fixture_read")!.execute({}, callContext)).rejects.toThrow("not available");
|
||||
expect(await decision("mcp_fixture_read")).toBe("deny");
|
||||
setRuntimeSessionContext({ ...baseContext, purpose: "task_collaboration" });
|
||||
const controller = new AbortController(); controller.abort();
|
||||
await expect(tools.get("mcp_fixture_write")!.execute({}, { ...callContext, signal: controller.signal })).rejects.toThrow();
|
||||
removeRuntimeSessionContext(sessionID);
|
||||
expect(await decision("mcp_fixture_read")).toBe("deny");
|
||||
await expect(tools.get("mcp_fixture_read")!.execute({}, callContext)).rejects.toThrow("unavailable");
|
||||
expect(calls).toEqual(["mcp_fixture_read"]);
|
||||
expect(await report()).toEqual({ configuration_errors: [], servers: [] });
|
||||
} finally {
|
||||
removeRuntimeSessionContext(sessionID);
|
||||
for (const [i, name] of ["AGENT_INTERNAL_TOKEN", "TJWATER_AGENT_INTERNAL_BASE_URL"].entries()) {
|
||||
if (previous[i] === undefined) delete process.env[name]; else process.env[name] = previous[i];
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("disables invalid global servers and reports only safe connection fields", async () => {
|
||||
const servers = new Map([
|
||||
["mcp_fixture", { type: "remote", url: "https://example.com/private", headers: { Authorization: "TEST_ONLY" } }],
|
||||
["invalid https://secret", { type: "remote", url: "file:///secret" }],
|
||||
]);
|
||||
const report = await registerMcpPolicy({
|
||||
mcp: {
|
||||
transform: async (fn: (editor: unknown) => void) => fn({ list: () => [...servers], set: (name: string, config: never) => servers.set(name, config), update: (name: string, fn: (config: unknown) => void) => fn(servers.get(name)) }),
|
||||
list: async () => ({ data: [{ name: "mcp_fixture", status: { status: "failed", error: "PRIVATE_CREDENTIAL" } }] }),
|
||||
},
|
||||
tool: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [] }), list: async () => [{ id: "mcp_fixture_read", options: { namespace: "mcp_fixture" } }] },
|
||||
permission: { hook: async () => {} }, session: { hook: async () => {} },
|
||||
} as never);
|
||||
expect(servers.get("invalid https://secret")).toMatchObject({ disabled: true });
|
||||
expect(await report()).toEqual({ configuration_errors: ["invalid_server_name"], servers: [{ name: "mcp_fixture", type: "remote", status: "failed", configured_protocol: "auto", tools: ["mcp_fixture_read"] }] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import express, { type Request } from "express";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { config } from "../../src/config.js";
|
||||
import { attachTrustedAgentContext, getLocalAgentContext, ProductContextError } from "../../src/context/localContext.js";
|
||||
import { registerMcpPolicyBridge, registerMcpStatusRoutes } from "../../src/routes/mcpRoutes.js";
|
||||
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
|
||||
describe("MCP routes", () => {
|
||||
it("protects the internal policy bridge against browser and context injection", async () => {
|
||||
const app = express(); app.use(express.json());
|
||||
registerMcpPolicyBridge(app, { internalToken: "MCP_TEST_ONLY", runtime: { mcpReadonlyTools: () => ["mcp_fixture_read"] } });
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/internal/mcp/session-policy`;
|
||||
const sessionID = "mcp-route-test";
|
||||
setRuntimeSessionContext({ sessionId: sessionID, clientSessionId: sessionID, actorKey: "PRIVATE_ACTOR", projectKey: "PRIVATE_PROJECT", traceId: "test", purpose: "task_execution" });
|
||||
const post = (body: unknown, headers: Record<string, string> = {}) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "MCP_TEST_ONLY", ...headers }, body: JSON.stringify(body) });
|
||||
try {
|
||||
expect((await post({ session_id: sessionID }, { "x-agent-internal-token": "wrong" })).status).toBe(403);
|
||||
expect((await post({ session_id: sessionID }, { Origin: "https://browser" })).status).toBe(403);
|
||||
expect((await post({ session_id: sessionID }, { "Sec-Fetch-Site": "same-origin" })).status).toBe(403);
|
||||
expect((await post({ session_id: sessionID, project_id: "other" })).status).toBe(400);
|
||||
expect((await post({ session_id: "missing" })).status).toBe(404);
|
||||
expect(await (await post({ session_id: sessionID })).json()).toEqual({ mode: "readonly", readonly_tools: ["mcp_fixture_read"] });
|
||||
} finally {
|
||||
removeRuntimeSessionContext(sessionID);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("requires verified session ownership and returns constant errors without credentials", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const previousEnv = config.NODE_ENV;
|
||||
const root = await mkdtemp(join(tmpdir(), "mcp-status-test-"));
|
||||
const store = new SessionMetadataStore(root);
|
||||
const app = express();
|
||||
let calls = 0;
|
||||
let failed = false;
|
||||
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
|
||||
if (String(input).endsWith("/api/v1/agent-auth-context")) {
|
||||
const headers = new Headers(init?.headers);
|
||||
return Response.json({ user_id: headers.get("Authorization") === "Bearer OWNER" ? "owner" : "other", project_id: headers.get("X-Project-Id"), network: "supply" });
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
config.NODE_ENV = "production";
|
||||
const owner = { header: (name: string) => ({ authorization: "Bearer OWNER", "x-project-id": "project" })[name.toLowerCase() as "authorization" | "x-project-id"] } as Request;
|
||||
await attachTrustedAgentContext(owner);
|
||||
await store.ensure({ ...getLocalAgentContext(owner), sessionId: "private-session" });
|
||||
const router = express.Router();
|
||||
router.use(async (req, res, next) => {
|
||||
try { await attachTrustedAgentContext(req); next(); }
|
||||
catch (error) { res.status(error instanceof ProductContextError ? error.status : 503).json({ message: "authentication failed" }); }
|
||||
});
|
||||
registerMcpStatusRoutes(router, { sessionMetadataStore: store, runtime: { mcpStatus: async () => {
|
||||
calls++;
|
||||
if (failed) throw new Error("SECRET_URL_AND_TOKEN");
|
||||
return { configuration_errors: [], servers: [{ name: "mcp_fixture", type: "remote", status: "needs_auth", configured_protocol: "auto", tools: [] }] };
|
||||
} } });
|
||||
app.use("/api/v1/agent/chat", router);
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/api/v1/agent/chat/session/private-session/mcp`;
|
||||
try {
|
||||
expect((await originalFetch(url)).status).toBe(401);
|
||||
const headers = { Authorization: "Bearer OWNER", "X-Project-Id": "project" };
|
||||
expect((await originalFetch(url, { headers: { ...headers, Authorization: "Bearer OTHER" } })).status).toBe(404);
|
||||
expect((await originalFetch(url, { headers: { ...headers, "X-Project-Id": "other" } })).status).toBe(404);
|
||||
expect(calls).toBe(0);
|
||||
expect(await (await originalFetch(url, { headers })).json()).toEqual({ servers: [{ name: "mcp_fixture", type: "remote", status: "needs_auth", configured_protocol: "auto", tools: [] }] });
|
||||
failed = true;
|
||||
const error = await originalFetch(url, { headers });
|
||||
expect(error.status).toBe(503);
|
||||
expect(await error.json()).toEqual({ message: "MCP runtime unavailable" });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch; config.NODE_ENV = previousEnv;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { mcpLocalEnvironment, mcpReadonlyTools, normalizeMcpConfig } from "../../src/runtime/mcp-config.js";
|
||||
import { canAutomaticallyApprove } from "../../src/routes/chatPermissionPolicy.js";
|
||||
|
||||
describe("MCP configuration and grants", () => {
|
||||
it("defaults to HTTP auto negotiation and stdio legacy while retaining explicit timeouts and protocol", () => {
|
||||
const config = normalizeMcpConfig({
|
||||
timeout: { execution: 90_000 },
|
||||
servers: {
|
||||
mcp_http: { type: "remote", url: "https://example.com/mcp" },
|
||||
mcp_stdio: { type: "local", command: ["bun", "server.ts"], timeout: { catalog: 2000 } },
|
||||
mcp_modern: { type: "local", command: ["server"], protocol: "2026-07-28", codemode: false },
|
||||
},
|
||||
});
|
||||
expect(config.timeout).toEqual({ startup: 10_000, catalog: 10_000, execution: 90_000 });
|
||||
expect(config.servers.mcp_http.protocol).toBe("auto");
|
||||
expect(config.servers.mcp_stdio).toMatchObject({ protocol: "legacy", timeout: { catalog: 2000 } });
|
||||
expect(config.servers.mcp_modern).toMatchObject({ protocol: "2026-07-28", codemode: false });
|
||||
expect(normalizeMcpConfig(undefined).servers).toEqual({});
|
||||
for (const servers of [
|
||||
{ unnamed: { type: "local", command: ["server"] } },
|
||||
{ mcp_bad: { type: "remote", url: "file:///secret" } },
|
||||
{ mcp_bad: { type: "remote", url: "https://user:secret@example.com/mcp" } },
|
||||
{ mcp_bad: { type: "remote", url: "https://example.com", protocol: "future" } },
|
||||
{ mcp_bad: { type: "local", command: [""] } },
|
||||
]) expect(() => normalizeMcpConfig({ servers })).toThrow();
|
||||
});
|
||||
|
||||
it("requires an exact service allow rule and keeps later denials and MCP confirmations effective", () => {
|
||||
expect(mcpReadonlyTools({ permissions: [{ action: "mcp_*", resource: "*", effect: "allow" }] })).toEqual([]);
|
||||
const permissions = [
|
||||
{ action: "mcp_*", resource: "*", effect: "ask" },
|
||||
{ action: "mcp_fixture_read", resource: "*", effect: "allow" },
|
||||
{ action: "mcp_fixture_write", resource: "*", effect: "allow" },
|
||||
{ action: "mcp_fixture_write", resource: "*", effect: "deny" },
|
||||
];
|
||||
expect(mcpReadonlyTools({ permissions })).toEqual(["mcp_fixture_read"]);
|
||||
expect(mcpReadonlyTools({ permissions, agents: { instruction: { permissions: [{ action: "mcp_fixture_read", resource: "*", effect: "deny" }] } } })).toEqual([]);
|
||||
expect(canAutomaticallyApprove("always", "mcp_fixture_write")).toBe(false);
|
||||
expect(canAutomaticallyApprove("always", "opencode_read_mcp_resource")).toBe(false);
|
||||
expect(canAutomaticallyApprove("always", "query_project_data")).toBe(true);
|
||||
});
|
||||
|
||||
it("shadows inherited credentials and reserved overrides for a trusted stdio process", () => {
|
||||
const env = mcpLocalEnvironment({ PATH: "/bin", HOME: "/home/test", DEEPSEEK_API_KEY: "TEST_ONLY", AGENT_INTERNAL_TOKEN: "TEST_ONLY", NODE_OPTIONS: "TEST_ONLY" }, {
|
||||
MCP_API_TOKEN: "MCP_ONLY", AGENT_INTERNAL_TOKEN: "OVERRIDE", OPENCODE_CONFIG_CONTENT: "OVERRIDE",
|
||||
});
|
||||
expect(env.PATH).toBe("/bin");
|
||||
expect(env.MCP_API_TOKEN).toBe("MCP_ONLY");
|
||||
for (const key of ["DEEPSEEK_API_KEY", "AGENT_INTERNAL_TOKEN", "NODE_OPTIONS", "OPENCODE_CONFIG_CONTENT"]) expect(env[key]).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -154,7 +154,11 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
remove: async () => { calls.push("session.remove"); },
|
||||
},
|
||||
model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } },
|
||||
rpc: { call: async () => { calls.push("tool.list"); return { output: toolIds.filter((id) => !missing.includes(id)).map((id) => ({ id, direct: true, sandboxed: id === "shell", workspaceRoot: root })) }; } },
|
||||
rpc: { call: async (input: { method: string }) => {
|
||||
if (input.method === "mcp") { calls.push("mcp.list"); return { output: { servers: [], configuration_errors: [] } }; }
|
||||
calls.push("tool.list");
|
||||
return { output: toolIds.filter((id) => !missing.includes(id)).map((id) => ({ id, direct: true, sandboxed: id === "shell", workspaceRoot: root })) };
|
||||
} },
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
||||
return { runtime, calls, cleanup: async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); } };
|
||||
@@ -162,7 +166,7 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
it("verifies business tools and model before reporting ready and removes the warmup session", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime();
|
||||
try { await runtime.warmup();
|
||||
expect(calls).toEqual(["health", "session.create", "tool.list", "model.list", "session.remove"]); } finally { await cleanup(); }
|
||||
expect(calls).toEqual(["health", "session.create", "tool.list", "mcp.list", "model.list", "session.remove"]); } finally { await cleanup(); }
|
||||
});
|
||||
it("fails readiness if a business tool was lost and still cleans up", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]);
|
||||
@@ -172,6 +176,19 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
});
|
||||
|
||||
describe("conversation workspace binding", () => {
|
||||
it("reads MCP status without migrating a legacy session or creating a workspace", async () => {
|
||||
let location: unknown;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({
|
||||
session: { get: async () => ({ location: { directory: process.cwd() } }), move: async () => { throw new Error("GET must not move sessions"); } },
|
||||
rpc: { call: async (input: { location: unknown }) => { location = input.location; return { output: { servers: [], configuration_errors: [] } }; } },
|
||||
}),
|
||||
ensureSessionWorkspace: async () => { throw new Error("GET must not create workspaces"); },
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
expect(await runtime.mcpStatus("legacy-session")).toEqual({ servers: [], configuration_errors: [] });
|
||||
expect(location).toEqual({ directory: process.cwd() });
|
||||
});
|
||||
|
||||
it("coalesces old-session migration and restores its persisted directory", async () => {
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
const root = await mkdtemp(join(tmpdir(), "runtime-workspace-test-"));
|
||||
|
||||
Reference in New Issue
Block a user