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.
130 lines
10 KiB
TypeScript
130 lines
10 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 { assertWebSearchReady } from "../../src/runtime/web-search-check.js";
|
|
import { QUERY_MCP_TOOL_IDS, LOCAL_QUERY_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
|
|
import { writeQueryMcpConnection } from "../../src/runtime/query-mcp-connection.js";
|
|
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
|
|
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
|
|
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
|
|
|
// Native discovery/execution only. Its fixture context hook stops all inference.
|
|
const root = await mkdtemp(join(tmpdir(), "tjwater-native-query-mcp-"));
|
|
const previousPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
|
|
const longCalculation = process.argv.includes("--long-calculation");
|
|
const pluginDirectory = join(root, "fixture-plugin");
|
|
await mkdir(pluginDirectory);
|
|
await writeFile(join(pluginDirectory, "package.json"), JSON.stringify({ name: "tjwater-query-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: () => QUERY_MCP_TOOL_IDS } });
|
|
const bridge = app.listen(0, "127.0.0.1");
|
|
await new Promise<void>((resolve) => bridge.once("listening", resolve));
|
|
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
|
|
let sessionId: string | undefined;
|
|
const receivedSessions: string[] = [];
|
|
const query = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY",
|
|
productClient: longCalculation ? { query: async (_id, _owner, name, args) => {
|
|
assert.equal(name, "run_simulation");
|
|
assert.equal(args.timeout_seconds, 90);
|
|
await Bun.sleep(65_000);
|
|
return { content: [{ type: "text", text: "isolated long calculation completed" }] };
|
|
} } : null,
|
|
productTools: longCalculation ? [{ name: "run_simulation", inputSchema: { type: "object", properties: {
|
|
timeout_seconds: { type: "integer", minimum: 1, maximum: 120, default: 120 },
|
|
}, additionalProperties: false } }] : [], resolveReader: async () => null,
|
|
executeLocal: async (name, args, context, signal) => {
|
|
receivedSessions.push(context.sessionId);
|
|
if (name === "todoread") {
|
|
return (await native!.client.rpc.call({ rpcID: "tjwater.tools", method: "read_todos", input: { session_id: context.sessionId }, location: { directory: context.workspaceDirectory! } }, { signal })).output;
|
|
}
|
|
return { query: args.query, actor: context.actorKey };
|
|
},
|
|
});
|
|
writeQueryMcpConnection(join(root, "sessions"), query.url, query.executionTimeoutMs);
|
|
try {
|
|
const source = JSON.parse(await readFile("opencode.json", "utf8"));
|
|
if (longCalculation) source.permissions = [...(source.permissions ?? []), {
|
|
action: "mcp_tjwater_run_simulation", resource: "*", effect: "allow",
|
|
}];
|
|
native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000,
|
|
config: serviceOpencodeConfig({ ...source, plugins: [pluginDirectory], mcp: { servers: {} }, websearch: { provider: "fixture-search" } }),
|
|
env: { AGENT_INTERNAL_TOKEN: "MCP_TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: `http://127.0.0.1:${(bridge.address() as { port: number }).port}`,
|
|
TJWATER_AGENT_MCP_CONNECTION_PATH: process.env.TJWATER_AGENT_MCP_CONNECTION_PATH!, TJWATER_AGENT_WORKSPACE_ROOT: join(root, "workspaces"),
|
|
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: "fixture-actor", projectKey: "personal-fixture", traceId: "test", purpose: "ordinary_chat", workspaceDirectory: session.location.directory });
|
|
await native.client.session.prompt({ sessionID: sessionId, text: "Materialize fixture tools; stop before inference." });
|
|
await native.client.session.wait({ sessionID: sessionId }, { signal: AbortSignal.timeout(20_000) });
|
|
const deadline = Date.now() + 10_000;
|
|
let tools: { id: string; direct: boolean }[] = [];
|
|
do {
|
|
tools = (await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: session.location })).output as typeof tools;
|
|
if (LOCAL_QUERY_TOOL_IDS.every((id) => tools.some((tool) => tool.id === id && tool.direct))) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
} while (Date.now() < deadline);
|
|
assert(LOCAL_QUERY_TOOL_IDS.every((id) => tools.some((tool) => tool.id === id && tool.direct)), "native MCP catalog must expose every independent query");
|
|
assert(tools.some((tool) => tool.id === "websearch"), "native websearch must remain registered");
|
|
await assertWebSearchReady(native.client, session.location.directory);
|
|
assert(!tools.some((tool) => tool.id.startsWith("browser_")), "unused native browser automation tools must not be registered");
|
|
const plugins = await native.client.plugin.list({ location: session.location });
|
|
assert(!plugins.data.some((plugin) => plugin.id === "opencode.browser" && plugin.state.status === "active"), "native browser plugin must be disabled");
|
|
for (const action of ["browser", "browser_click"]) {
|
|
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "deny");
|
|
}
|
|
for (const action of ["present_answer", "show_chart"]) {
|
|
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "allow");
|
|
}
|
|
for (const id of ["opencode_models", "opencode_session_move", "opencode_session_rename",
|
|
"opencode_worktree_create", "opencode_worktree_list", "opencode_worktree_remove"]) {
|
|
assert(tools.some((tool) => tool.id === id), `${id} must remain registered`);
|
|
}
|
|
assert(!tools.some((tool) => tool.id === "mcp_tjwater_web_search"), "web search must not have a duplicate MCP tool");
|
|
for (const id of ["query_project_data", "query_operational_task_data", "get_operational_task_context", "session_search", "web_search", "geocode", "todoread", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]) assert(!tools.some((tool) => tool.id === id), `${id} must not remain a standalone plugin query`);
|
|
for (const id of ["memory_manager", "skill_manager", "send_task_message", "reply_agent_message", "todowrite",
|
|
"present_answer", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history",
|
|
"render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report",
|
|
"publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence",
|
|
"request_task_data_access"]) assert(tools.some((tool) => tool.id === id), `${id} must remain a plugin tool`);
|
|
for (const tool of ["mcp_tjwater_session_search", "mcp_tjwater_todoread"]) {
|
|
const result = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: { session_id: sessionId, tool, arguments: tool.endsWith("session_search") ? { query: "native-mcp" } : {} }, location: session.location }, { signal: AbortSignal.timeout(10_000) });
|
|
assert.equal((result.output as { error?: string }).error, undefined, JSON.stringify(result.output));
|
|
assert((result.output as { evaluated: string[] }).evaluated.includes(tool));
|
|
}
|
|
assert.deepEqual(receivedSessions, [sessionId, sessionId], "OpenCode must supply the invoking session in native MCP metadata");
|
|
const search = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: {
|
|
session_id: sessionId, tool: "websearch", arguments: { query: "native-search-fixture" },
|
|
}, location: session.location }, { signal: AbortSignal.timeout(10_000) });
|
|
assert.equal((search.output as { error?: string }).error, undefined, JSON.stringify(search.output));
|
|
assert(JSON.stringify(search.output).includes("https://example.test/search-result"), "native websearch must execute through its selected provider");
|
|
if (longCalculation) {
|
|
setRuntimeSessionContext({ sessionId, clientSessionId: sessionId, actorKey: "fixture-actor", projectKey: "fixture-project", projectId: "fixture-project", traceId: "test", purpose: "ordinary_chat", workspaceDirectory: session.location.directory });
|
|
const started = performance.now();
|
|
console.log("Starting isolated native MCP delay: requested budget 90 seconds, fixture delay 65 seconds.");
|
|
const result = await native.client.rpc.call({ rpcID: "tjwater.mcp-fixture", method: "call", input: {
|
|
session_id: sessionId, tool: "mcp_tjwater_run_simulation", arguments: { timeout_seconds: 90 },
|
|
}, location: session.location }, { signal: AbortSignal.timeout(110_000) });
|
|
const output = result.output as { error?: string; content: unknown };
|
|
assert(!output.error, output.error);
|
|
assert(JSON.stringify(output.content).includes("isolated long calculation completed"));
|
|
const elapsed = performance.now() - started;
|
|
assert(elapsed >= 60_000, `Long-call fixture returned too early (${Math.round(elapsed)}ms)`);
|
|
console.log(`Native MCP long calculation passed after ${Math.round(elapsed)}ms: a 90-second Agent budget crosses the old 30/35/60-second limits; no real simulation or model inference.`);
|
|
}
|
|
console.log("Native query MCP smoke passed: browser automation absent; business UI and managers retained; discovery, permissions, session metadata, checklist RPC and native websearch verified; no model inference or external search.");
|
|
} finally {
|
|
if (sessionId) { removeRuntimeSessionContext(sessionId); await native?.client.session.remove({ sessionID: sessionId }).catch(() => {}); }
|
|
native?.close();
|
|
if (native) await native.exited;
|
|
await query.close();
|
|
await new Promise<void>((resolve) => bridge.close(() => resolve()));
|
|
if (previousPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH; else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = previousPath;
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|