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:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user