Files
jiang 6a49d2209e feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
2026-10-19 18:46:58 +08:00

101 lines
7.9 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(".opencode/test-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"),
TJWATER_AGENT_MCP_CONNECTION_PATH: join(root, "absent-query-mcp.json"),
DEEPSEEK_API_KEY: "TEST_ONLY_NEVER_USED", OPENCODE_DB: join(root, "data", "opencode.db"), 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 });
}