From 0f2f30d31e6a98d38e404a987c1f4a7ac6f3d463 Mon Sep 17 00:00:00 2001 From: omartelo Date: Tue, 27 Oct 2026 09:37:34 -0300 Subject: [PATCH] feat(opencode): leave lich's operations to its MCP server when lich registered it A lich that hands opencode its MCP server at spawn puts it under mcp.lich in OPENCODE_CONFIG_CONTENT. The module now defines no tools then, so each operation is listed once and browser_* comes with the rest. An older lich registers nothing and keeps the JavaScript tools. --- CHANGELOG.md | 8 ++++++++ README.md | 13 ++++++------- docs/opencode-tools.md | 17 +++++++++++------ opencode/lich.js | 20 ++++++++++++++++---- tests/opencode-tools.test.mjs | 22 ++++++++++++++++++++++ 5 files changed, 63 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f715766..d0e8549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **opencode leaves lich's operations to lich's MCP server.** A lich that hands + opencode its MCP server at spawn puts it in `OPENCODE_CONFIG_CONTENT`, and the + module then defines no tools of its own, so each operation is listed once and + the `browser_*` tools are there with the rest. Under an older lich, which + registers nothing, the tools are defined as before (`docs/opencode-tools.md`). + ### Fixed - **A Codex card is titled again.** Codex 0.147.0 and later write no diff --git a/README.md b/README.md index 9deb0d9..2066e5a 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,12 @@ The agent side of the [lich](https://github.com/lichdotdev/lich) integration. li The four are what Claude Code, Codex, Antigravity, opencode and omp report. **Crush reports two of them** — its session id and the git-status refresh — because `PreToolUse` is the only event it has, and a state nothing can end is worse on a card than no state. **omp reports all four minus the bell**: no event of its own for "your turn" has been measured yet, so its card shows a spinner while the agent waits on you. [docs/providers.md](docs/providers.md) has the event mapping per harness. -On **opencode** it also carries the other direction: the seven operations lich -offers a session for driving the sessions beside it — list, send, wait, reply, -open, close, and read the worktrees — as tools of opencode's own. Claude Code and -Codex get those as MCP tools lich registers when it spawns them; opencode cannot -be told about a server on its command line, so they are defined in the module -instead. [docs/opencode-tools.md](docs/opencode-tools.md) has the list and the -two cases where they are deliberately absent. +On **opencode** under a lich from before it registered its MCP server there, it +also carries the other direction: the operations lich offers a session for +driving the sessions beside it, as tools of opencode's own. A newer lich hands +opencode that server at spawn, as it does Claude Code and Codex, and the module +then defines none. [docs/opencode-tools.md](docs/opencode-tools.md) has the +list and the cases where they are deliberately absent. On **Claude Code** it carries the other direction too, from lich to the session: a mod, a module Claude Code runs in its own process, takes the commands given diff --git a/docs/opencode-tools.md b/docs/opencode-tools.md index 6e7cc7c..a03a21b 100644 --- a/docs/opencode-tools.md +++ b/docs/opencode-tools.md @@ -8,10 +8,11 @@ them into folders. lich exposes those operations twice, and both are documented in [`docs/cli.md`](https://github.com/lichdotdev/lich/blob/main/docs/cli.md) there: -as the `lich` command line, and as MCP tools it registers at spawn for the -harnesses that can be told on their own command line — Claude Code and Codex. -**opencode cannot be**, and a plugin there cannot register an MCP server either. -What it can do is define tools, so that is what `opencode/lich.js` does. +as the `lich` command line, and as MCP tools it registers at spawn — for +opencode through `$OPENCODE_CONFIG_CONTENT`, the inline config it merges over +its files. A lich from before that registration hands opencode nothing, and a +plugin there cannot register an MCP server, but it can define tools, so that is +what `opencode/lich.js` does for those sessions. ## What is registered @@ -54,9 +55,13 @@ environment belongs to this session. ## When they are absent -Two cases, both deliberate, and in both the **reports keep working** — they need -neither of the things below: +Three cases, all deliberate, and in each the **reports keep working** — they +need none of the things below: +- **lich registered its server.** A `lich` entry under `mcp` in + `$OPENCODE_CONFIG_CONTENT` means the session already has every operation from + lich's own MCP server, `browser_*` included. Defining them here too would list + each one twice. - **Outside lich.** No `LICH_PORT` / `LICH_TOKEN` / `LICH_SESSION_ID` in the environment means no tools registered at all. Tools that could only answer "no lich is running" would be that many tools in the prompt of every diff --git a/opencode/lich.js b/opencode/lich.js index 2e8d16b..9591b94 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -129,11 +129,12 @@ function runner($) { // lichTools is the other half of this plugin: the reports above tell lich what a // session is doing, and these let that session act on the ones beside it. // -// opencode has no way to register an MCP server from a plugin, which is how -// Claude Code and Codex get the same operations — but it does let a plugin -// define tools, so the tools are defined here and the transport disappears. +// A lich that hands opencode its MCP server at spawn puts it in +// $OPENCODE_CONFIG_CONTENT, and every operation is there under the `lich` +// server, so nothing is defined here: each would be listed twice. A lich from +// before that registers nothing, and its sessions get the tools below instead. // -// Two guards, both about not breaking what already works: +// Two more guards, both about not breaking what already works: // // - Nothing is registered outside lich. The module is a no-op there by // design, and tools that could only answer "no lich is running" would be @@ -149,10 +150,21 @@ function runner($) { // because opencode loads *every* export of a plugin module as a plugin: a // second export would be called with a plugin input and its return value read // for hook keys, which took the server down when it was null. +// lichServerRegistered reports whether lich put its MCP server in opencode's +// inline config at spawn. A value that is not JSON registers nothing. +function lichServerRegistered() { + try { + return Boolean(JSON.parse(process.env.OPENCODE_CONFIG_CONTENT ?? "{}")?.mcp?.lich) + } catch { + return false + } +} + async function lichTools($, helper) { if (!$ || !process.env.LICH_PORT || !process.env.LICH_TOKEN || !process.env.LICH_SESSION_ID) { return null } + if (lichServerRegistered()) return null let tool = helper if (!tool) { try { diff --git a/tests/opencode-tools.test.mjs b/tests/opencode-tools.test.mjs index 9e9a047..ddb0908 100644 --- a/tests/opencode-tools.test.mjs +++ b/tests/opencode-tools.test.mjs @@ -117,6 +117,28 @@ test('every operation lich offers is a tool', async () => { }) }) +// A lich that hands opencode its MCP server at spawn puts it in the inline +// config, and every operation is there under the `lich` server. Defining them +// again would list each one twice; an older lich, which registers nothing, +// still gets them here. +test('no tools when lich registered its MCP server at spawn', async () => { + const registered = JSON.stringify({ mcp: { lich: { type: 'local', command: ['/opt/lich/lich', 'mcp'] } } }) + await withEnv({ ...INSIDE_LICH, OPENCODE_CONFIG_CONTENT: registered }, async () => { + const plugin = await LichPlugin({ $: fakeShell().$ }, fakeHelper()) + assert.equal(plugin.tool, undefined) + assert.equal(typeof plugin.event, 'function', 'the reports must not go with the tools') + }) +}) + +test('an inline config without lich in it keeps the tools', async () => { + for (const content of ['{"mcp":{"mine":{}}}', '{"model":"m"}', 'not json']) { + await withEnv({ ...INSIDE_LICH, OPENCODE_CONFIG_CONTENT: content }, async () => { + const plugin = await LichPlugin({ $: fakeShell().$ }, fakeHelper()) + assert.ok(plugin.tool?.list_sessions, `${content}: tools dropped`) + }) + } +}) + test('the lich a session belongs to is the one that runs', async () => { const shell = fakeShell({ stdout: '[]' }) await inLich(shell, async (tools) => {