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.
100 lines
7.8 KiB
TypeScript
100 lines
7.8 KiB
TypeScript
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 });
|
|
}
|