diff --git a/README.md b/README.md index 23bb41d..202597f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ This relative path assumes the configuration file is in the selected global conf The plugin supplies session-local `todoread` and `todowrite` tools when no other plugin provides them. In this pinned V2 beta, custom tools cannot open permission prompts: an `ask` or `deny` rule blocks these todo tools rather than bypassing the rule. Explicitly allow them if you want to use them. +When an MCP server named `codegraph` is already configured, its tools are exposed directly to `coordinate` and retain the built-in `build` agent's matching allow, ask, and deny rules. The plugin does not configure or start a missing server, and no other MCP server is exposed this way. + ## Uninstall Remove `plugins/stanp-opencode` from the selected config directory and remove any explicit configuration entry for it, then restart OpenCode. diff --git a/src/index.ts b/src/index.ts index 6cf4195..9ee65cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,9 @@ export default Plugin.define({ Agent.ID.make("build"), ).permissions; let permissionKey = JSON.stringify(inherited); + let hasCodegraph = false; + let codegraphTools: string[] = []; + let agentTransformReady = false; const controller = new AbortController(); // Subscribe before the first read: configuration can publish its catalog while // asynchronous setup is running. @@ -80,6 +83,31 @@ export default Plugin.define({ let workers: (typeof workerNames)[number][] = []; const active = new Set(); + await context.mcp.transform((editor) => { + hasCodegraph = editor.get("codegraph") !== undefined; + if (hasCodegraph) + editor.update("codegraph", (server) => { + server.codemode = false; + }); + }); + await context.tool.transform((editor) => { + const next = editor + .list() + .map((tool) => tool.id) + .filter((id) => id.startsWith("codegraph_")) + .sort(); + if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return; + codegraphTools = next; + if (agentTransformReady) + queueMicrotask(() => { + void context.agent + .reload() + .catch((error) => + console.error("stanp-opencode agent reload failed", error), + ); + }); + }); + await context.agent.transform((editor) => { const deep = selected.mode === "custom" || models.has("gpt-6-astra"); const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-")); @@ -146,6 +174,13 @@ export default Plugin.define({ .filter((rule) => matches(action, rule.action)) .map((rule) => ({ ...rule, action })), ), + ...(hasCodegraph + ? codegraphTools.flatMap((action) => + inherited + .filter((rule) => matches(action, rule.action)) + .map((rule) => ({ ...rule, action })), + ) + : []), ...workers.map((resource) => ({ action: "subagent", resource, @@ -154,6 +189,7 @@ export default Plugin.define({ ]; }); }); + agentTransformReady = true; async function assertTodo(agentID: Agent.ID, action: string) { const { data: agent } = await context.agent.get({ agentID }); const effect = diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index aa86b2c..a8f685b 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -5,6 +5,7 @@ import type { DeepMutable } from "@opencode/plugin/promise/types"; import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool"; import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client"; import type { SessionContext } from "@opencode/plugin/promise/session"; +import { z } from "zod"; import { gptAgents } from "../src/config.js"; import plugin from "../src/index.js"; @@ -26,6 +27,11 @@ type FixtureSetup = ( async function host( models: ModelInfo[] = allModels, options: Plugin.Context["options"] = { provider: "test" }, + configuredMcp: Record< + string, + { type: "local"; command: string[]; codemode?: boolean; disabled?: boolean } + > = {}, + configuredMcpTools: string[] = [], ) { const agents = new Map>(); const build: DeepMutable = Agent.Info.default(Agent.ID.make("build")); @@ -48,6 +54,50 @@ async function host( }); const storage = new Map(); const tools = new Map(); + const mcpTools = new Set(configuredMcpTools); + const mcpServers = new Map(Object.entries(structuredClone(configuredMcp))); + let mcpTransform!: (editor: { + get(name: string): (typeof configuredMcp)[string] | undefined; + update(name: string, update: (server: (typeof configuredMcp)[string]) => void): void; + }) => void; + const toolTransforms: ((editor: ToolEditor) => void)[] = []; + const editMcp = () => ({ + get: (name: string) => mcpServers.get(name), + update(name: string, update: (server: (typeof configuredMcp)[string]) => void) { + const server = mcpServers.get(name); + if (server) update(server); + }, + }); + const editTools = (): ToolEditor => ({ + get(id) { + const tool = tools.get(id); + return tool ? { ...tool, id } : undefined; + }, + list: () => [ + ...[...tools].map(([id, tool]) => ({ ...tool, id })), + ...[...mcpTools].map((id) => ({ + id, + name: id, + description: id, + input: z.object({}), + options: { codemode: false }, + async execute() { + return { content: "" }; + }, + })), + ], + namespace() {}, + add(tool) { + tools.set(tool.name, tool); + }, + update(id, update) { + const tool = tools.get(id); + if (tool) update(tool); + }, + remove(id) { + tools.delete(id); + }, + }); // SAFETY: This test host implements the native APIs used by setup; unrelated context // services are deliberately absent so unexpected new dependencies fail the test. const context = { @@ -73,6 +123,13 @@ async function host( storage.set(key, value); }, }, + mcp: { + async transform(callback: typeof mcpTransform) { + mcpTransform = callback; + callback(editMcp()); + return registration; + }, + }, agent: { async list() { return { data: [...agents.values()] }; @@ -119,24 +176,8 @@ async function host( }, tool: { async transform(callback: (editor: ToolEditor) => void) { - callback({ - get(id) { - const tool = tools.get(id); - return tool ? { ...tool, id } : undefined; - }, - list: () => [...tools].map(([id, tool]) => ({ ...tool, id })), - namespace() {}, - add(tool) { - tools.set(tool.name, tool); - }, - update(id, update) { - const tool = tools.get(id); - if (tool) update(tool); - }, - remove(id) { - tools.delete(id); - }, - }); + toolTransforms.push(callback); + callback(editTools()); return registration; }, async hook(_name: string, callback: typeof before) { @@ -153,6 +194,7 @@ async function host( return { agents, build, + mcpServers, defaultAgent, originalBuild, async refresh(next: ModelInfo[]) { @@ -167,6 +209,21 @@ async function host( events.enqueue({ type: "agent.updated" }); await reloaded.promise; }, + async changeCodegraph( + server: + | { type: "local"; command: string[]; codemode?: boolean; disabled?: boolean } + | undefined, + toolNames: string[], + ) { + if (server) mcpServers.set("codegraph", server); + else mcpServers.delete("codegraph"); + mcpTools.clear(); + for (const name of toolNames) mcpTools.add(name); + reloaded = Promise.withResolvers(); + mcpTransform(editMcp()); + for (const callback of toolTransforms) callback(editTools()); + await reloaded.promise; + }, async todo(name: string, sessionID: string, input: JsonValue = {}) { // SAFETY: Todo executors use only sessionID and agent from the native context. return tools @@ -257,6 +314,71 @@ test("worker inheritance and coordinator isolation preserve build and default", }); }); +test("coordinator inherits only codegraph MCP permissions when that server exists", async () => { + const absent = await host(); + expect( + absent.agents + .get("coordinate") + ?.permissions.some((rule) => rule.action.startsWith("codegraph_")), + ).toBe(false); + expect(absent.mcpServers.has("codegraph")).toBe(false); + + const result = await host( + allModels, + { provider: "test" }, + { + codegraph: { type: "local", command: ["codegraph"], codemode: true }, + docs: { type: "local", command: ["docs"], codemode: true }, + }, + ["codegraph_codegraph_search", "codegraph_future_query", "docs_search"], + ); + expect(result.mcpServers.get("codegraph")?.codemode).toBe(false); + expect(result.mcpServers.get("docs")?.codemode).toBe(true); + let coordinate = result.agents.get("coordinate")!; + expect(coordinate.permissions).toContainEqual({ + action: "codegraph_codegraph_search", + resource: "*", + effect: "allow", + }); + expect(coordinate.permissions.some((rule) => rule.action.startsWith("docs_"))).toBe(false); + expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false); + expect(coordinate.permissions.some((rule) => rule.action === "execute")).toBe(false); + + await result.changeBuildPermissions([ + { action: "*", resource: "*", effect: "allow" }, + { action: "code*", resource: "*", effect: "ask" }, + { action: "*search", resource: "*", effect: "deny" }, + { action: "docs_*", resource: "*", effect: "allow" }, + { action: "shell", resource: "*", effect: "allow" }, + ]); + coordinate = result.agents.get("coordinate")!; + expect(coordinate.permissions).toContainEqual({ + action: "codegraph_future_query", + resource: "*", + effect: "ask", + }); + expect(coordinate.permissions).toContainEqual({ + action: "codegraph_codegraph_search", + resource: "*", + effect: "deny", + }); + expect(coordinate.permissions.some((rule) => rule.action === "docs_*")).toBe(false); + expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false); + + await result.changeCodegraph(undefined, []); + coordinate = result.agents.get("coordinate")!; + expect(coordinate.permissions.some((rule) => rule.action.startsWith("codegraph_"))).toBe(false); + await result.changeCodegraph({ type: "local", command: ["codegraph"], codemode: true }, [ + "codegraph_added_later", + ]); + expect(result.mcpServers.get("codegraph")?.codemode).toBe(false); + expect(result.agents.get("coordinate")?.permissions).toContainEqual({ + action: "codegraph_added_later", + resource: "*", + effect: "ask", + }); +}); + test("manual calls and command-created child sessions cannot bypass delegation restrictions", async () => { const result = await host(allModels.slice(0, 2)); for (const invoke of [result.delegate, result.command]) {