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:
2026-10-08 18:36:07 +08:00
parent 7383ed6d13
commit 75d8d526f9
19 changed files with 728 additions and 4 deletions
+27
View File
@@ -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) }; }
},
});
},
});
+17
View File
@@ -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`);
}
+2 -1
View File
@@ -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 () => {} },
+99
View File
@@ -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 });
}
+93
View File
@@ -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"] }] });
});
});
+85
View File
@@ -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 });
}
});
});
+52
View File
@@ -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("");
});
});
+19 -2
View File
@@ -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-"));