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.
101 lines
7.9 KiB
TypeScript
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 });
|
|
}
|