From 5b867e8fba39c7fe0955986a6fc83e750b2b5e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:03:58 +0800 Subject: [PATCH 1/2] feat: coordinate MCP and skill materialization --- README.md | 7 ++++++ deno.json | 2 +- orax.toml | 2 +- package.json | 2 +- src/handlers/acp.ts | 4 ++-- src/handlers/effects.ts | 17 ++++++++++---- src/main.ts | 4 ++-- tests/host-simulator.ts | 49 ++++++++++++++++++++++++++++++++++++----- 8 files changed, 70 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e85b355..22c8ada 100644 --- a/README.md +++ b/README.md @@ -100,3 +100,10 @@ against your own OpenCode instead. a provider login need a plugin restart. - Killing the CLI on agent stop is best effort; Ora retains process-tree reaping as a backstop. + +## Project Effects + +Ora manages both `.opencode/skills` and the `mcp` entries in +`.opencode/opencode.json` as one target projection. User-owned JSON/JSONC +content is preserved, MCP secrets remain in Ora's configuration store, and +OpenCode is quiesced and restarted once after all safe mutations complete. diff --git a/deno.json b/deno.json index 3a8b200..17bd3d3 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@ora-space/opencode-agent", - "version": "0.1.0", + "version": "0.6.0", "exports": "./src/main.ts", "minimumDependencyAge": 0, "imports": { diff --git a/orax.toml b/orax.toml index 56dcdc0..be83b7d 100644 --- a/orax.toml +++ b/orax.toml @@ -3,7 +3,7 @@ title = "OpenCode" identifier = "ora-space.opencode" namespace = "official" kind = "agent" -version = "0.5.0" +version = "0.6.0" description = "Ora Space OpenCode Agent" homepage = "https://github.com/ora-space/opencode-agent" license = "Apache-2.0" diff --git a/package.json b/package.json index b4cb27c..32a7621 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ora-space/opencode-agent", - "version": "0.1.0", + "version": "0.6.0", "description": "Ora agent plugin that runs OpenCode as an ACP agent.", "type": "module", "license": "Apache-2.0", diff --git a/src/handlers/acp.ts b/src/handlers/acp.ts index da34f44..dad8da8 100644 --- a/src/handlers/acp.ts +++ b/src/handlers/acp.ts @@ -1,5 +1,5 @@ import type { JsonValue } from "@ora-space/plugin-sdk"; -import type { SkillEffectCoordinator } from "./effects.ts"; +import type { AgentEffectCoordinator } from "./effects.ts"; import type { OpenCodeClient } from "../services/opencode-client.ts"; /** @@ -16,7 +16,7 @@ import type { OpenCodeClient } from "../services/opencode-client.ts"; */ export function forwardAcpFrame( client: OpenCodeClient, - effects: SkillEffectCoordinator, + effects: AgentEffectCoordinator, frame: JsonValue, ): Promise | void { if (effects.intercept(frame)) { diff --git a/src/handlers/effects.ts b/src/handlers/effects.ts index 50e301e..4d37a36 100644 --- a/src/handlers/effects.ts +++ b/src/handlers/effects.ts @@ -21,6 +21,15 @@ export const SKILLS_RESOURCE: EffectResourceDeclaration = { coordination: "quiesce_before_mutation", }; +/** The shared project MCP file OpenCode reads at startup. */ +export const MCP_RESOURCE: EffectResourceDeclaration = { + workspaceRelativePath: ".opencode/opencode.json", + // SDK 0.8 narrows this field to Skills even though the host protocol already accepts MCP. + materializationFormat: + "ora/opencode-mcp-config.v1" as typeof SKILL_DIRECTORY_V1, + coordination: "quiesce_before_mutation", +}; + const SESSION_PROMPT_METHOD = "session/prompt"; /** The code this plugin reports a Consumer call it cannot satisfy right now under. */ @@ -51,7 +60,7 @@ const QUIESCE_POLL_MS = 50; * what was held, and `verifyReady` reports whether the process Ora is about to mark ready is one * that has actually read the Skills on disk. */ -export class SkillEffectCoordinator { +export class AgentEffectCoordinator { readonly #client: OpenCodeClient; readonly #cwd: () => string | undefined; readonly #openTurns = new Set(); @@ -64,7 +73,7 @@ export class SkillEffectCoordinator { } readonly definition: AgentEffectDefinition = { - resources: [SKILLS_RESOURCE], + resources: [SKILLS_RESOURCE, MCP_RESOURCE], coordinate: (context) => this.#coordinate(context), reactivate: (context) => this.#reactivate(context), verifyReady: (context) => this.#verifyReady(context), @@ -177,13 +186,13 @@ export class SkillEffectCoordinator { if (!this.#client.running) { throw new PluginMethodError( CONSUMER_NOT_READY, - "the OpenCode CLI is not running, so it has read no Skills", + "the OpenCode CLI is not running, so it has read neither Skills nor MCP configuration", ); } if (this.#held !== undefined) { throw new PluginMethodError( CONSUMER_NOT_READY, - "OpenCode is quiesced for a Skill mutation and has not rescanned yet", + "OpenCode is quiesced for a project Effect mutation and has not reloaded yet", ); } return { diff --git a/src/main.ts b/src/main.ts index 1762e1f..dedcbaa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,7 @@ import { runAgentPlugin, } from "./base/agent-plugin.ts"; import { forwardAcpFrame } from "./handlers/acp.ts"; -import { SkillEffectCoordinator } from "./handlers/effects.ts"; +import { AgentEffectCoordinator } from "./handlers/effects.ts"; import { startOpenCode, stopOpenCode } from "./handlers/lifecycle.ts"; import { listOpenCodeModels } from "./handlers/models.ts"; import { OpenCodeClient } from "./services/opencode-client.ts"; @@ -51,7 +51,7 @@ class OpenCodeAgentPlugin extends AgentPlugin { }, }); - readonly #effects = new SkillEffectCoordinator(this.#client, () => this.#cwd); + readonly #effects = new AgentEffectCoordinator(this.#client, () => this.#cwd); override readonly effects = this.#effects.definition; diff --git a/tests/host-simulator.ts b/tests/host-simulator.ts index 32e9d79..2ff3af8 100644 --- a/tests/host-simulator.ts +++ b/tests/host-simulator.ts @@ -167,6 +167,7 @@ interface SimulatedChildProcess { const simulatedChildProcesses = new Map(); let nextChildProcessId = 1; +let agentProcessSpawns = 0; /** Recognizes a plugin-to-host request for `ora/childprocess/*`. */ function isChildProcessRequest( @@ -279,11 +280,29 @@ async function dispatchChildProcessMethod( const command = resolveSimulatedProgram(params); const args = (params.args as string[] | undefined) ?? []; const cwd = (params.cwd as string | null | undefined) ?? undefined; + const requestedEnvironment = (params.env ?? {}) as Record; + if ( + Object.keys(requestedEnvironment).some((key) => + key.startsWith("ORA_MCP_") + ) + ) { + throw new SimulatedSpawnError( + "invalid_params", + "reserved MCP environment", + ); + } + if (args.includes("acp")) { + agentProcessSpawns += 1; + } let child: Deno.ChildProcess; try { child = new Deno.Command(command, { args, cwd, + env: { + ...requestedEnvironment, + ORA_MCP_SIMULATED: "host-only-test-value", + }, stdin: "piped", stdout: "piped", stderr: "piped", @@ -399,11 +418,24 @@ const register = await waitFor( ); console.log(`ok: register ${JSON.stringify(register.params)}`); -const effectResources = - (register.params as { effectResources?: unknown[] } | undefined) - ?.effectResources ?? []; -if (effectResources.length === 0) { - throw new Error("registration did not declare any Effect Resource"); +const effectResources = (register.params as + | { effectResources?: Record[] } + | undefined) + ?.effectResources ?? []; +const resourceSignatures = effectResources.map((resource) => + `${resource.workspaceRelativePath}:${resource.materializationFormat}` +).sort(); +const expectedResourceSignatures = [ + ".opencode/opencode.json:ora/opencode-mcp-config.v1", + ".opencode/skills:ora/skill-directory.v1", +]; +if ( + JSON.stringify(resourceSignatures) !== + JSON.stringify(expectedResourceSignatures) +) { + throw new Error( + `unexpected Effect Resources: ${JSON.stringify(effectResources)}`, + ); } console.log(`ok: effectResources ${JSON.stringify(effectResources)}`); @@ -481,7 +513,7 @@ console.log( // from these, so any stable pair drives the same code the host would. const coordinationParams = { targetId: "sim-target", - resourceIds: ["sim-resource"], + resourceIds: ["sim-skills", "sim-mcp"], }; await send({ @@ -575,6 +607,11 @@ const sessionAfterRestart = await waitFor( (message) => message.method === "agent/acp" && acpFrame(message).id === 3, "ACP session/new after restart", ); +if (agentProcessSpawns !== 2) { + throw new Error( + `expected one initial spawn and one shared Effect restart, got ${agentProcessSpawns}`, + ); +} console.log( `ok: session/new after restart ${ JSON.stringify( From 10f99206c7625927b1c41753e5d9f44d0af9f7a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:19:25 +0800 Subject: [PATCH 2/2] feat: forward ACP mcpServers and drop MCP file materialization Keep Skill Effect. Session MCP is host-injected through session/new and session/load; this plugin no longer writes OpenCode config or registers an MCP Effect Resource. --- README.md | 9 +++--- deno.json | 3 +- deno.lock | 12 +++++-- src/handlers/effects.ts | 13 ++------ tests/acp-forward.test.ts | 68 +++++++++++++++++++++++++++++++++++++++ tests/host-simulator.ts | 4 +-- 6 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 tests/acp-forward.test.ts diff --git a/README.md b/README.md index 22c8ada..dea7c39 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ against your own OpenCode instead. ## Project Effects -Ora manages both `.opencode/skills` and the `mcp` entries in -`.opencode/opencode.json` as one target projection. User-owned JSON/JSONC -content is preserved, MCP secrets remain in Ora's configuration store, and -OpenCode is quiesced and restarted once after all safe mutations complete. +Ora manages `.opencode/skills` as a Skill Effect Resource. Configured MCP +plugins are not written into `.opencode/opencode.json`; Ora injects them through +ACP `session/new` and `session/load` `mcpServers`, which this plugin forwards +unchanged. Secret values stay in Ora's configuration store and never appear in +Workspace files, logs, or `ORA_MCP_*` environment variables. diff --git a/deno.json b/deno.json index 17bd3d3..d3b9d7e 100644 --- a/deno.json +++ b/deno.json @@ -11,9 +11,10 @@ "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61" }, "tasks": { - "check": "deno check src/main.ts scripts/package.ts tests/host-simulator.ts", + "check": "deno check src/main.ts scripts/package.ts tests/host-simulator.ts tests/acp-forward.test.ts", "lint": "deno lint src scripts tests bundle.config.ts", "format": "deno fmt src scripts tests bundle.config.ts deno.json package.json README.md", + "test": "deno test --allow-read tests/acp-forward.test.ts", "simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts", "dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts", "build": "deno bundle src/main.ts -o dist/main.js", diff --git a/deno.lock b/deno.lock index 3007d63..3383185 100644 --- a/deno.lock +++ b/deno.lock @@ -2,8 +2,10 @@ "version": "5", "specifiers": { "jsr:@ora-space/plugin-sdk@0.8.0": "0.8.0", + "jsr:@std/assert@1": "1.0.19", "jsr:@std/cli@^1.0.32": "1.0.32", "jsr:@std/fmt@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/streams@^1.0.17": "1.1.2", @@ -14,11 +16,17 @@ "@ora-space/plugin-sdk@0.8.0": { "integrity": "22a3d97572b49755a8b087f73219aa34b0b4fcb42d6e1940498b928e3c732b6a" }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/cli@1.0.32": { "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", "dependencies": [ "jsr:@std/fmt", - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/fmt@1.0.10": { @@ -30,7 +38,7 @@ "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/streams@1.1.2": { diff --git a/src/handlers/effects.ts b/src/handlers/effects.ts index 4d37a36..163f19d 100644 --- a/src/handlers/effects.ts +++ b/src/handlers/effects.ts @@ -21,15 +21,6 @@ export const SKILLS_RESOURCE: EffectResourceDeclaration = { coordination: "quiesce_before_mutation", }; -/** The shared project MCP file OpenCode reads at startup. */ -export const MCP_RESOURCE: EffectResourceDeclaration = { - workspaceRelativePath: ".opencode/opencode.json", - // SDK 0.8 narrows this field to Skills even though the host protocol already accepts MCP. - materializationFormat: - "ora/opencode-mcp-config.v1" as typeof SKILL_DIRECTORY_V1, - coordination: "quiesce_before_mutation", -}; - const SESSION_PROMPT_METHOD = "session/prompt"; /** The code this plugin reports a Consumer call it cannot satisfy right now under. */ @@ -73,7 +64,7 @@ export class AgentEffectCoordinator { } readonly definition: AgentEffectDefinition = { - resources: [SKILLS_RESOURCE, MCP_RESOURCE], + resources: [SKILLS_RESOURCE], coordinate: (context) => this.#coordinate(context), reactivate: (context) => this.#reactivate(context), verifyReady: (context) => this.#verifyReady(context), @@ -186,7 +177,7 @@ export class AgentEffectCoordinator { if (!this.#client.running) { throw new PluginMethodError( CONSUMER_NOT_READY, - "the OpenCode CLI is not running, so it has read neither Skills nor MCP configuration", + "the OpenCode CLI is not running, so it has not read project Skills", ); } if (this.#held !== undefined) { diff --git a/tests/acp-forward.test.ts b/tests/acp-forward.test.ts new file mode 100644 index 0000000..96a20de --- /dev/null +++ b/tests/acp-forward.test.ts @@ -0,0 +1,68 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import type { JsonValue } from "@ora-space/plugin-sdk"; +import { + AgentEffectCoordinator, + SKILLS_RESOURCE, +} from "../src/handlers/effects.ts"; +import { OpenCodeClient } from "../src/services/opencode-client.ts"; + +/** Secret-bearing stdio and HTTP servers the host injects through ACP. */ +function mcpServers(): JsonValue[] { + return [ + { + type: "stdio", + name: "ora-space/tavily-search", + command: "/pkg/assets/server", + args: ["."], + env: [{ name: "TAVILY_API_KEY", value: "super-secret" }], + }, + { + type: "http", + name: "ora-space/alpha-search", + url: "https://mcp.example.test/mcp", + headers: [{ name: "Authorization", value: "Bearer super-secret" }], + }, + ]; +} + +Deno.test("registers only the Skill Effect Resource", () => { + const effects = new AgentEffectCoordinator( + new OpenCodeClient(), + () => undefined, + ); + assertEquals(effects.definition.resources, [SKILLS_RESOURCE]); +}); + +Deno.test("does not intercept session/new mcpServers", () => { + const effects = new AgentEffectCoordinator( + new OpenCodeClient(), + () => undefined, + ); + const frame = { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { cwd: "/workspace", mcpServers: mcpServers() }, + }; + assertEquals(effects.intercept(frame), false); + assertEquals(frame.params.mcpServers, mcpServers()); +}); + +Deno.test("does not intercept session/load mcpServers", () => { + const effects = new AgentEffectCoordinator( + new OpenCodeClient(), + () => undefined, + ); + const frame = { + jsonrpc: "2.0", + id: 3, + method: "session/load", + params: { + sessionId: "ses_1", + cwd: "/workspace", + mcpServers: mcpServers(), + }, + }; + assertEquals(effects.intercept(frame), false); + assertEquals(frame.params.mcpServers, mcpServers()); +}); diff --git a/tests/host-simulator.ts b/tests/host-simulator.ts index 2ff3af8..6fa8c83 100644 --- a/tests/host-simulator.ts +++ b/tests/host-simulator.ts @@ -301,7 +301,6 @@ async function dispatchChildProcessMethod( cwd, env: { ...requestedEnvironment, - ORA_MCP_SIMULATED: "host-only-test-value", }, stdin: "piped", stdout: "piped", @@ -426,7 +425,6 @@ const resourceSignatures = effectResources.map((resource) => `${resource.workspaceRelativePath}:${resource.materializationFormat}` ).sort(); const expectedResourceSignatures = [ - ".opencode/opencode.json:ora/opencode-mcp-config.v1", ".opencode/skills:ora/skill-directory.v1", ]; if ( @@ -513,7 +511,7 @@ console.log( // from these, so any stable pair drives the same code the host would. const coordinationParams = { targetId: "sim-target", - resourceIds: ["sim-skills", "sim-mcp"], + resourceIds: ["sim-skills"], }; await send({