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.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { beforeEach, afterEach, describe, expect, it } from "bun:test";
|
||||
import plugin from "../../.opencode/plugins/tjwater-tools.js";
|
||||
import { AGENT_MESSAGE_TOOLS, registerAgentMessagePolicy } from "../../.opencode/agent-message-policy.js";
|
||||
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
import { TASK_AGENT, TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
|
||||
|
||||
const originalConnectionPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
|
||||
beforeEach(() => { process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = `/tmp/tjwater-mcp-absent-${process.pid}.json`; });
|
||||
afterEach(() => {
|
||||
if (originalConnectionPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
|
||||
else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = originalConnectionPath;
|
||||
});
|
||||
|
||||
|
||||
const taskId = "60000000-0000-4000-8000-000000000001";
|
||||
const runId = "60000000-0000-4000-8000-000000000002";
|
||||
const messageId = "60000000-0000-4000-8000-000000000003";
|
||||
const requestId = "60000000-0000-4000-8000-000000000004";
|
||||
let bridgeServer: ReturnType<typeof Bun.serve> | undefined;
|
||||
afterEach(() => { bridgeServer?.stop(true); bridgeServer = undefined; });
|
||||
|
||||
async function runTools(script: string) {
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, AGENT_INTERNAL_TOKEN: "MESSAGE_TEST_ONLY", TJWATER_AGENT_INTERNAL_TOKEN: "",
|
||||
TJWATER_AGENT_INTERNAL_BASE_URL: `http://127.0.0.1:${bridgeServer!.port}` },
|
||||
stdout: "pipe", stderr: "pipe",
|
||||
});
|
||||
const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
||||
expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" });
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
describe("controlled investigation message tools", () => {
|
||||
it("registers exact direct tools and rejects identity or arbitrary session targets at dispatch", async () => {
|
||||
const tools = new Map<string, any>();
|
||||
await plugin.setup({
|
||||
tool: { transform: async (fn: any) => fn({ list: () => [...tools].map(([id, tool]) => ({ ...tool, id })),
|
||||
add: (tool: any) => tools.set(tool.name, tool), remove: (name: string) => tools.delete(name), update: () => {} }),
|
||||
list: async () => [...tools].map(([id, tool]) => ({ ...tool, id })) },
|
||||
mcp: { transform: async (fn: any) => fn({ list: () => [] }), list: async () => ({ data: [] }) },
|
||||
shell: { hook: async () => {} }, permission: { hook: async () => {} }, session: { hook: async () => {} },
|
||||
rpc: { register: async () => {} }, storage: { set: async () => {}, get: async () => undefined },
|
||||
} as never);
|
||||
for (const name of ["send_task_message", "reply_agent_message"]) expect(tools.get(name).options).toEqual({ codemode: false, permission: name });
|
||||
for (const name of AGENT_MESSAGE_TOOLS.filter((name) => name.startsWith("mcp_"))) expect(tools.has(name)).toBeFalse();
|
||||
expect(tools.has("memory_manager")).toBeTrue();
|
||||
expect(tools.has("skill_manager")).toBeTrue();
|
||||
const send = tools.get("send_task_message");
|
||||
for (const extra of [{ session_id: "other" }, { project_id: "other" }, { authorization: "secret" }, { operation: "reply" }]) {
|
||||
await expect(send.execute({ task_id: taskId, run_id: runId, message: "question", request_id: requestId, ...extra },
|
||||
{ sessionID: "native-owner", id: "native-call" })).rejects.toThrow();
|
||||
}
|
||||
expect(tools.has("message")).toBeFalse();
|
||||
});
|
||||
|
||||
it("passes each fixed operation through the authenticated bridge without rewriting delivery or provenance", async () => {
|
||||
const calls: Array<{ operation: string; token: string | null; method: string; body: unknown }> = [];
|
||||
bridgeServer = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
|
||||
const operation = new URL(request.url).pathname;
|
||||
calls.push({ operation, token: request.headers.get("x-agent-internal-token"), method: request.method, body: await request.json() });
|
||||
return Response.json({ status: "queued", message_id: messageId, originalMessage: { role: "agent", message: "source retained" } });
|
||||
} });
|
||||
const inputs = [{ task_id: taskId, run_id: runId, message: "check current evidence", request_id: requestId },
|
||||
{ message_id: messageId, message: "evidence reviewed", request_id: requestId }];
|
||||
const output = await runTools([
|
||||
`const names = ${JSON.stringify(["send_task_message", "reply_agent_message"])}; const inputs = ${JSON.stringify(inputs)};`,
|
||||
'const results = []; for (let i = 0; i < names.length; i++) { const { default: tool } = await import(`./.opencode/tools/${names[i]}.ts`); results.push(JSON.parse(await tool.execute(inputs[i], { sessionID: "native-owner" }))); }',
|
||||
"process.stdout.write(JSON.stringify(results));",
|
||||
].join("\n"));
|
||||
expect(calls).toEqual(["send", "reply"].map((operation, index) => ({
|
||||
operation: `/internal/agent-messages/${operation}`, token: "MESSAGE_TEST_ONLY", method: "POST",
|
||||
body: { session_id: "native-owner", input: inputs[index] },
|
||||
})));
|
||||
expect(output).toEqual(inputs.map(() => ({ status: "queued", message_id: messageId, originalMessage: { role: "agent", message: "source retained" } })));
|
||||
});
|
||||
|
||||
it("validates UUIDs, bounds, empty messages and forbidden fields before network access and honors cancellation", async () => {
|
||||
let received = 0;
|
||||
bridgeServer = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { received++; return Response.json({}); } });
|
||||
const cases = [
|
||||
["send_task_message", { task_id: taskId, run_id: runId, message: "", request_id: requestId }],
|
||||
["send_task_message", { task_id: taskId, run_id: runId, message: "x".repeat(8001), request_id: requestId }],
|
||||
["send_task_message", { task_id: taskId, run_id: "invalid", message: "q", request_id: requestId }],
|
||||
["reply_agent_message", { message_id: "invalid", message: "a", request_id: requestId }],
|
||||
["reply_agent_message", { message_id: messageId, message: "a", request_id: "invalid" }],
|
||||
["reply_agent_message", { message_id: messageId, message: "a", request_id: requestId, session_id: "foreign" }],
|
||||
];
|
||||
const output = await runTools([
|
||||
`const cases = ${JSON.stringify(cases)}; let rejected = 0;`,
|
||||
'for (const [name, input] of cases) { const { default: tool } = await import(`./.opencode/tools/${name}.ts`); try { await tool.execute(input, { sessionID: "owner" }); } catch { rejected++; } }',
|
||||
'const { default: read } = await import("./.opencode/tools/reply_agent_message.ts"); const controller = new AbortController(); controller.abort();',
|
||||
`let cancelled = false; try { await read.execute(${JSON.stringify({ message_id: messageId, message: "a", request_id: requestId })}, { sessionID: "owner", signal: controller.signal }); } catch { cancelled = true; }`,
|
||||
"process.stdout.write(JSON.stringify({ rejected, cancelled }));",
|
||||
].join("\n"));
|
||||
expect(output).toEqual({ rejected: cases.length, cancelled: true });
|
||||
expect(received).toBe(0);
|
||||
});
|
||||
|
||||
it("fails a denied bridge response rather than claiming a message was queued", async () => {
|
||||
bridgeServer = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { return Response.json({ error: "TASK_RUN_NOT_ACTIVE" }, { status: 409 }); } });
|
||||
const output = await runTools([
|
||||
'import send from "./.opencode/tools/send_task_message.ts";',
|
||||
`let error = ""; try { await send.execute(${JSON.stringify({ task_id: taskId, run_id: runId, message: "q", request_id: requestId })}, { sessionID: "owner" }); } catch (caught) { error = caught.message; }`,
|
||||
"process.stdout.write(JSON.stringify({ error }));",
|
||||
].join("\n"));
|
||||
expect(output.error).toContain("agent message send failed (409)");
|
||||
expect(output.error).toContain("TASK_RUN_NOT_ACTIVE");
|
||||
});
|
||||
|
||||
it("limits static profiles and catalogs while preserving ordinary-chat subagent denial and administrator rules", async () => {
|
||||
const administratorDeny = { action: "send_task_message", resource: "*", effect: "deny" };
|
||||
const config = serviceOpencodeConfig({ agents: { instruction: { permissions: [administratorDeny] } } });
|
||||
for (const action of AGENT_MESSAGE_TOOLS) {
|
||||
expect(config.agents.instruction.permissions.find((rule) => rule.action === action)?.effect).toBe("allow");
|
||||
expect(config.agents[TASK_AGENT].permissions.find((rule) => rule.action === action)?.effect)
|
||||
.toBe(["mcp_tjwater_read_agent_messages", "reply_agent_message"].includes(action) ? "allow" : "deny");
|
||||
}
|
||||
expect(config.agents.instruction.permissions.at(-2)).toEqual(administratorDeny);
|
||||
expect(config.agents.instruction.permissions.at(-1)).toEqual({ action: "subagent", resource: "*", effect: "deny" });
|
||||
expect(config.agents[TASK_READER_AGENT].permissions.some((rule) => (AGENT_MESSAGE_TOOLS as readonly string[]).includes(rule.action))).toBeFalse();
|
||||
let hook: (event: any) => Promise<void> = async () => {};
|
||||
await registerAgentMessagePolicy({ session: { hook: async (_name: string, fn: typeof hook) => { hook = fn; } } } as never);
|
||||
for (const [agent, expected] of [["instruction", AGENT_MESSAGE_TOOLS], [TASK_AGENT, AGENT_MESSAGE_TOOLS.slice(3)], [TASK_READER_AGENT, []]] as const) {
|
||||
const event = { agent, sessionID: "owner", tools: Object.fromEntries(AGENT_MESSAGE_TOOLS.map((name) => [name, {}])) };
|
||||
await hook(event);
|
||||
expect(Object.keys(event.tools)).toEqual([...expected]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,8 @@ describe("disabled opencode tools", () => {
|
||||
expect(existsSync(`.opencode/tools/${toolName}.ts`)).toBeFalse();
|
||||
}
|
||||
expect(existsSync(".opencode/tools/server_api_shared.ts")).toBeFalse();
|
||||
expect(existsSync(".opencode/tools/web_search.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/geocode.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/web_search.ts")).toBeFalse();
|
||||
expect(existsSync(".opencode/tools/geocode.ts")).toBeFalse();
|
||||
expect(existsSync(".opencode/tools/zoom_to_map.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/frontend_action.ts")).toBeTrue();
|
||||
expect(existsSync(".opencode/tools/render_scada_analysis.ts")).toBeTrue();
|
||||
|
||||
@@ -15,10 +15,9 @@ describe("OpenCode internal bridge tools", () => {
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
receivedToken = request.headers.get("x-agent-internal-token");
|
||||
receivedSessionId = url.searchParams.get("session_id");
|
||||
return Response.json({ task: { task_id: "task-1" } });
|
||||
receivedSessionId = null;
|
||||
return request.json().then((body) => { receivedSessionId = body.session_id; return Response.json({ task: { task_id: "task-1" } }); });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,8 +25,8 @@ describe("OpenCode internal bridge tools", () => {
|
||||
processExecPath(),
|
||||
"-e",
|
||||
[
|
||||
'import tool from "./.opencode/tools/get_operational_task_context.ts";',
|
||||
'const result = await tool.execute({}, { sessionID: "runtime-session-1" });',
|
||||
'import tool from "./.opencode/tools/memory_manager.ts";',
|
||||
'const result = await tool.execute({ action: "list", scope: "user", reason: "review existing memory" }, { sessionID: "runtime-session-1" });',
|
||||
"process.stdout.write(result);",
|
||||
].join("\n"),
|
||||
], {
|
||||
@@ -58,8 +57,8 @@ describe("OpenCode internal bridge tools", () => {
|
||||
processExecPath(),
|
||||
"-e",
|
||||
[
|
||||
'import tool from "./.opencode/tools/get_operational_task_context.ts";',
|
||||
'await tool.execute({}, { sessionID: "runtime-session-1" });',
|
||||
'import tool from "./.opencode/tools/memory_manager.ts";',
|
||||
'await tool.execute({ action: "list", scope: "user", reason: "review existing memory" }, { sessionID: "runtime-session-1" });',
|
||||
].join("\n"),
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { beforeEach, afterEach, describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import type { Plugin } from "../../.opencode/node_modules/@opencode/plugin/dist/promise/index.js";
|
||||
import { registerMcpPolicy } from "../../.opencode/mcp-policy.js";
|
||||
import { registerMcpPolicyBridge } from "../../src/routes/mcpRoutes.js";
|
||||
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
const originalConnectionPath = process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
|
||||
beforeEach(() => { process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = `/tmp/tjwater-mcp-absent-${process.pid}.json`; });
|
||||
afterEach(() => {
|
||||
if (originalConnectionPath === undefined) delete process.env.TJWATER_AGENT_MCP_CONNECTION_PATH;
|
||||
else process.env.TJWATER_AGENT_MCP_CONNECTION_PATH = originalConnectionPath;
|
||||
});
|
||||
|
||||
|
||||
type Context = Parameters<Plugin.Plugin["setup"]>[0];
|
||||
type Evaluation = Parameters<Parameters<Context["permission"]["hook"]>[1]>[0];
|
||||
type Tool = Awaited<ReturnType<Context["tool"]["list"]>>[number];
|
||||
@@ -77,6 +85,7 @@ describe("MCP session enforcement", () => {
|
||||
it("disables invalid global servers and reports only safe connection fields", async () => {
|
||||
const servers = new Map([
|
||||
["mcp_fixture", { type: "remote", url: "https://example.com/private", headers: { Authorization: "TEST_ONLY" } }],
|
||||
["mcp_tjwater_query", { type: "remote", url: "https://example.com/collision" }],
|
||||
["invalid https://secret", { type: "remote", url: "file:///secret" }],
|
||||
]);
|
||||
const report = await registerMcpPolicy({
|
||||
@@ -88,6 +97,7 @@ describe("MCP session enforcement", () => {
|
||||
permission: { hook: async () => {} }, session: { hook: async () => {} },
|
||||
} as never);
|
||||
expect(servers.get("invalid https://secret")).toMatchObject({ disabled: true });
|
||||
expect(await report()).toEqual({ configuration_errors: ["invalid_server_name"], servers: [{ name: "mcp_fixture", type: "remote", status: "failed", configured_protocol: "auto", tools: ["mcp_fixture_read"] }] });
|
||||
expect(servers.get("mcp_tjwater_query")).toMatchObject({ disabled: true });
|
||||
expect(await report()).toEqual({ configuration_errors: ["invalid_server_name", "mcp_tjwater_query"], servers: [{ name: "mcp_fixture", type: "remote", status: "failed", configured_protocol: "auto", tools: ["mcp_fixture_read"] }] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { queryMcpConnection } from "../../.opencode/query-mcp.js";
|
||||
import { writeQueryMcpConnection } from "../../src/runtime/query-mcp-connection.js";
|
||||
|
||||
const previous = { path: process.env.TJWATER_AGENT_MCP_CONNECTION_PATH, token: process.env.AGENT_INTERNAL_TOKEN };
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const [key, value] of [["TJWATER_AGENT_MCP_CONNECTION_PATH", previous.path], ["AGENT_INTERNAL_TOKEN", previous.token]]) {
|
||||
if (value === undefined) delete process.env[key!]; else process.env[key!] = value;
|
||||
}
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
it("configures a pinned native MCP connection using the host-owned endpoint and private channel credential", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "query-connection-")); roots.push(root);
|
||||
process.env.AGENT_INTERNAL_TOKEN = "MCP_TEST_ONLY";
|
||||
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp");
|
||||
expect(queryMcpConnection()).toMatchObject({ type: "remote", protocol: "2026-07-28", oauth: false, codemode: false, headers: { "x-agent-internal-token": "MCP_TEST_ONLY" } });
|
||||
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", 3_665_000);
|
||||
expect(queryMcpConnection()?.timeout?.execution).toBe(3_665_000);
|
||||
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", Infinity);
|
||||
expect(() => queryMcpConnection()).toThrow("timeout");
|
||||
writeQueryMcpConnection(join(root, "sessions"), "https://foreign.test/mcp");
|
||||
expect(() => queryMcpConnection()).toThrow("loopback");
|
||||
});
|
||||
Reference in New Issue
Block a user