feat(opencode): register ctx tools natively via plugin (#574) (#597)

Move OpenCode/Kilo from plugin+MCP dual registration to plugin-native ctx_*
tools. The plugin now imports the shared server tool registry without
starting stdio, exposes all 11 ctx_* tools via the OpenCode/Kilo tool map,
and uses AsyncLocalStorage to pass project/session context into existing
handlers without a process.env race.

Upgrade safety for existing users:
- configureAllHooks removes only legacy mcp.context-mode while preserving
  other MCP servers.
- doctor warns when a legacy mcp.context-mode block remains and points to
  context-mode upgrade.
- stale legacy OpenCode/Kilo MCP children suppress ctx_* registration and
  become no-op rather than exposing duplicate tools.

Safety cleanup:
- Remove CONTEXT_MODE_IDLE_TIMEOUT_MS entirely; plugin-native tools remove
  the need for timer-driven MCP death, and timer shutdown was unsafe for
  hosts that keep registered tool handles.
- Guard process-wide exception handlers so importing server.js for native
  tools does not alter OpenCode/Kilo host crash semantics.
- Scope CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS to the dynamic import and restore
  it so child commands do not inherit the internal guard.

Tests cover native tool registration, native ctx_stats execution, host
side-effect leakage, native session attribution, legacy MCP config cleanup,
doctor warning, and stale MCP no-op predicate.

Refs: #574, #565, #592

Co-authored-by: Ousama Ben Younes <benyounes.ousama@gmail.com>
This commit is contained in:
Omer Cohen
2026-05-18 17:10:16 +02:00
committed by GitHub
co-authored by Ousama Ben Younes
parent 7d5b52948f
commit 09efefdc4a
15 changed files with 752 additions and 307 deletions
+12 -14
View File
@@ -421,17 +421,11 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"context-mode": {
"type": "local",
"command": ["context-mode"]
}
},
"plugin": ["context-mode"]
}
```
The `mcp` entry registers all 11 MCP tools. The `plugin` entry enables hooks — OpenCode calls the plugin's TypeScript functions directly before and after each tool execution, blocking dangerous commands and enforcing sandbox routing.
The `plugin` entry registers all 11 `ctx_*` tools natively and enables hooks — OpenCode calls context-mode's TypeScript plugin in-process, so there is no redundant stdio MCP child per session.
3. *(Optional)* Copy the routing rules file. The model needs an `AGENTS.md` file for routing awareness:
@@ -471,17 +465,11 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
```json
{
"$schema": "https://app.kilo.ai/config.json",
"mcp": {
"context-mode": {
"type": "local",
"command": ["context-mode"]
}
},
"plugin": ["context-mode"]
}
```
The `mcp` entry registers all 11 MCP tools. The `plugin` entry enables hooks — KiloCode calls the plugin's TypeScript functions directly before and after each tool execution, blocking dangerous commands and enforcing sandbox routing.
The `plugin` entry registers all 11 `ctx_*` tools natively and enables hooks — KiloCode calls context-mode's TypeScript plugin in-process, so there is no redundant stdio MCP child per session.
3. *(Optional)* Copy the routing rules file. KiloCode shares the OpenCode plugin architecture, so the model needs an `AGENTS.md` file for routing awareness:
@@ -1397,6 +1385,16 @@ That blocks loopback + RFC1918 + ULA in addition to the always-blocked ranges. U
`tool_input` for any `mcp__*` tool call is also redacted before persistence — keys matching `authorization`, `token`, `secret`, `password`, `api_key`, `cookie`, `signature`, `private_key` get masked to `[REDACTED]` so credentials in MCP arguments don't end up in the session DB.
### Lifecycle environment variables
One runtime knob controls MCP sibling cleanup. Idle self-shutdown was removed after [#592](https://github.com/mksglu/context-mode/issues/592): hosts can keep registered tool handles after a clean MCP exit, making a timer-driven exit unsafe.
| Variable | Default | Purpose |
|---|---|---|
| `CONTEXT_MODE_STARTUP_SWEEP` | `1` (enabled) | At boot, a newly-spawned MCP child reaps any other context-mode MCP server pids that share its parent process (`sameParentOnly: true` — never touches MCP children of a different host). This reclaims accumulated siblings immediately instead of waiting for each idle timer to fire. Set to `0` or `false` to disable (useful when you intentionally want multiple concurrent MCP children under the same host, e.g. multi-tenant test runners). |
`CONTEXT_MODE_STARTUP_SWEEP` is read fresh at MCP server start — no restart of the host CLI is required, just spawn a new MCP child (open a new session) for changes to take effect. Unrecognized values fall back to enabled.
### Routing-guidance environment variables
| Variable | Default | Purpose |
+152 -150
View File
File diff suppressed because one or more lines are too long
+3 -7
View File
@@ -1,10 +1,6 @@
{
"$schema": "https://app.kilo.ai/config.json",
"mcp": {
"context-mode": {
"type": "local",
"command": ["context-mode"]
}
},
"plugin": ["context-mode"]
"plugin": [
"context-mode"
]
}
+3 -7
View File
@@ -1,10 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"context-mode": {
"type": "local",
"command": ["context-mode"]
}
},
"plugin": ["context-mode"]
"plugin": [
"context-mode"
]
}
+4 -3
View File
@@ -47,7 +47,7 @@ This puts the `context-mode` binary in PATH, which is required for:
| **Config location** | `~/.claude/settings.json` | `~/.gemini/settings.json` | `.github/hooks/*.json` | `.github/hooks/*.json` | `.cursor/hooks.json` or `~/.cursor/hooks.json` | `opencode.json` | `~/.codex/hooks.json` + `~/.codex/config.toml` | `~/.gemini/antigravity/mcp_config.json` | `~/.kiro/settings/mcp.json` | `~/.omp/agent/mcp_config.json` |
| **Session ID field** | `session_id` | `session_id` | `sessionId` (camelCase) | `sessionId` (camelCase) | `conversation_id` | `sessionID` (camelCase) | N/A | N/A | N/A | N/A |
| **Project dir env** | `CLAUDE_PROJECT_DIR` | `GEMINI_PROJECT_DIR` | `CLAUDE_PROJECT_DIR` | `CLAUDE_PROJECT_DIR` | stdin `workspace_roots` | `ctx.directory` (plugin init) | N/A | N/A | N/A | `OMP_PROCESSING_AGENT_DIR` |
| **MCP tool naming** | `mcp__server__tool` | `mcp__server__tool` | `f1e_` prefix | `f1e_` prefix | `MCP:<tool>` in hook payloads | `mcp__server__tool` | `mcp__server__tool` | `mcp__server__tool` | `mcp__server__tool` | `mcp__server__tool` |
| **MCP/tool naming** | `mcp__server__tool` | `mcp__server__tool` | `f1e_` prefix | `f1e_` prefix | `MCP:<tool>` in hook payloads | native `ctx_*` plugin tools | `mcp__server__tool` | `mcp__server__tool` | `mcp__server__tool` | `mcp__server__tool` |
| **Hook command format** | `context-mode hook claude-code <event>` | `context-mode hook gemini-cli <event>` | `context-mode hook vscode-copilot <event>` | `context-mode hook jetbrains-copilot <event>` | `context-mode hook cursor <event>` | TS plugin (no command) | `context-mode hook codex <event>` | N/A | N/A |
| **Hook registration** | settings.json hooks object | settings.json hooks object | `.github/hooks/*.json` | `.github/hooks/*.json` | `hooks.json` native hook arrays | opencode.json plugin array | `~/.codex/hooks.json` | N/A | N/A |
| **MCP server command** | `context-mode` (or plugin auto) | `context-mode` | `context-mode` | `context-mode` | `context-mode` | `context-mode` | `context-mode` | `context-mode` | `context-mode` |
@@ -145,11 +145,11 @@ context-mode hook gemini-cli sessionstart
### OpenCode
**Status:** Partially supported
**Status:** Fully supported
**Hook Paradigm:** TS Plugin
OpenCode uses a TypeScript plugin paradigm instead of JSON stdin/stdout. Hooks are registered via the `plugin` array in `opencode.json`.
OpenCode uses a TypeScript plugin paradigm instead of JSON stdin/stdout. Hooks and the 11 `ctx_*` tools are registered via the `plugin` array in `opencode.json`; no separate `mcp` block or stdio MCP child is required.
**Hook Names:**
- `tool.execute.before` -- equivalent to PreToolUse
@@ -172,6 +172,7 @@ OpenCode uses a TypeScript plugin paradigm instead of JSON stdin/stdout. Hooks a
**Configuration:**
- `opencode.json` or `.opencode/opencode.json`
- Plugin registered in the `plugin` array with npm package names
- `ctx_*` tools are native plugin tools, not `mcp__server__tool` calls
**Cross-session resume:**
When OpenCode triggers `experimental.session.compacting` (auto on context overflow OR manual `/compact`), context-mode saves a snapshot to its per-project SQLite store. The NEXT new session in the same project — typically after `Ctrl+D` then re-running `opencode`, or starting a fresh chat — claims that snapshot via `experimental.chat.system.transform` and prepends it to `system[1]` (preserves OpenCode's `[header, body]` cache fold). The current session never claims its OWN snapshot back (self-injection guard, v1.0.106). To verify the injection landed, run with `OPENCODE_DEBUG=1` and grep for `<!-- context-mode v` in the system prompt — that's the visible marker.
-8
View File
@@ -19,14 +19,6 @@ SMOKE_DIR="$REPO_ROOT/scripts/tier2-smoke"
LOG_DIR="${RUNNER_TEMP:-/tmp}/tier2-smoke-pi"
mkdir -p "$LOG_DIR"
# Disable MCP idle self-shutdown for the smoke run (#583 follow-up).
# v1.0.132+ idles the MCP child after CONTEXT_MODE_IDLE_TIMEOUT_MS of no
# activity (default 15 min). The Pi adapter's bridge auto-respawns on the
# next tool call — which would mask a real silent-death regression by
# making it look like a normal idle exit. Pin to 0 here so any MCP exit
# during the smoke is genuinely anomalous and shows up in the log.
export CONTEXT_MODE_IDLE_TIMEOUT_MS=0
PI_BIN="${PI_BIN:-$(command -v pi || true)}"
PI_HEADLESS_FLAGS="${PI_HEADLESS_FLAGS:---headless}"
FIXTURE="${FIXTURE:-$SMOKE_DIR/fixtures/search-corpus.txt}"
+98 -96
View File
File diff suppressed because one or more lines are too long
+30
View File
@@ -408,6 +408,15 @@ export class OpenCodeAdapter extends BaseAdapter implements HookAdapter {
});
}
if (this.hasLegacyContextModeMcp(settings)) {
results.push({
check: "Legacy MCP registration",
status: "warn",
message: "mcp.context-mode is redundant: ctx_* tools are now provided by the plugin",
fix: "context-mode upgrade (removes only mcp.context-mode; preserves other MCP servers)",
});
}
// Note: SessionStart handled via experimental.chat.system.transform surrogate
results.push({
check: "SessionStart hook",
@@ -480,6 +489,17 @@ export class OpenCodeAdapter extends BaseAdapter implements HookAdapter {
}
settings.plugin = plugins;
const mcp = settings.mcp;
if (mcp && typeof mcp === "object" && !Array.isArray(mcp)) {
const servers = mcp as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(servers, "context-mode")) {
delete servers["context-mode"];
changes.push("Removed legacy context-mode MCP block (plugin-native tools)");
}
if (Object.keys(servers).length === 0) delete settings.mcp;
}
this.writeSettings(settings);
return changes;
}
@@ -522,6 +542,16 @@ export class OpenCodeAdapter extends BaseAdapter implements HookAdapter {
return Array.isArray(plugins) && plugins.some((p: unknown) => typeof p === "string" && p.includes("context-mode"));
}
private hasLegacyContextModeMcp(settings: Record<string, unknown>): boolean {
const mcp = settings.mcp;
return !!(
mcp &&
typeof mcp === "object" &&
!Array.isArray(mcp) &&
Object.prototype.hasOwnProperty.call(mcp, "context-mode")
);
}
/**
* Extract session ID from OpenCode hook input.
* OpenCode uses camelCase sessionID.
+81
View File
@@ -83,6 +83,25 @@ type PluginContext = {
directory: string;
};
type NativeToolContext = {
sessionID: string;
messageID: string;
agent: string;
directory: string;
worktree?: string;
abort?: AbortSignal;
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void;
};
type NativeToolDefinition = {
description: string;
args: Record<string, unknown>;
execute: (
args: Record<string, unknown>,
ctx: NativeToolContext,
) => Promise<string | { title?: string; output: string; metadata?: Record<string, unknown> }>;
};
/** OpenCode tool.execute.before — first parameter */
interface BeforeHookInput {
tool: string;
@@ -370,7 +389,69 @@ async function createContextModePlugin(ctx: PluginContext) {
}
}
async function buildNativeTools(): Promise<Record<string, NativeToolDefinition>> {
// Import the existing MCP server registry without starting its stdio
// transport. This is the plugin-only bridge for #574: OpenCode/Kilo
// call ctx_* tools in-process through Hooks.tool instead of spawning
// a separate MCP child per session.
const prevEmbedded = process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS = "1";
let mod: typeof import("../../server.js");
try {
mod = await import("../../server.js");
} finally {
if (prevEmbedded === undefined) delete process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
else process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS = prevEmbedded;
}
const tools: Record<string, NativeToolDefinition> = {};
for (const registered of mod.REGISTERED_CTX_TOOLS) {
const config = registered.config as Record<string, unknown>;
const schema = config.inputSchema as { shape?: unknown; _def?: { shape?: unknown } } | undefined;
const shape =
typeof schema?.shape === "object" && schema.shape !== null
? schema.shape
: typeof schema?._def?.shape === "function"
? (schema._def.shape as () => unknown)()
: {};
tools[registered.name] = {
description: String(config.description ?? ""),
args: shape as Record<string, unknown>,
async execute(args: Record<string, unknown>, toolCtx: NativeToolContext) {
toolCtx.metadata?.({ title: String(config.title ?? registered.name) });
const project = toolCtx.directory || projectDir;
const result = await mod.withProjectDirOverride({ projectDir: project, sessionId: toolCtx.sessionID }, async () =>
registered.handler(args ?? {}),
);
const r = result as {
content?: Array<{ type?: string; text?: string }>;
isError?: boolean;
};
const text = Array.isArray(r?.content)
? r.content
.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n")
: typeof result === "string"
? result
: JSON.stringify(result ?? "");
if (r?.isError) throw new Error(text || `${registered.name} returned an error`);
return { title: String(config.title ?? registered.name), output: text };
},
};
}
return tools;
}
const nativeTools = await buildNativeTools();
return {
tool: nativeTools,
// ── PreToolUse: Routing enforcement ─────────────────
"tool.execute.before": async (input: BeforeHookInput, output: BeforeHookOutput) => {
+1 -1
View File
@@ -509,7 +509,7 @@ export class MCPStdioClient {
}
/**
* Respawn the MCP child after an exit (clean idle shutdown or crash).
* Respawn the MCP child after an exit (clean shutdown or crash).
* Resets state so a fresh `start()` + `initialize()` cycle runs, then
* the caller's pending request flows through the new child.
*
+167 -12
View File
@@ -8,6 +8,7 @@ import { join, dirname, resolve, sep, isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir, tmpdir, cpus } from "node:os";
import { request as httpsRequest } from "node:https";
import { AsyncLocalStorage } from "node:async_hooks";
import { z } from "zod";
import { PolyglotExecutor } from "./executor.js";
import { runPool, type PoolJob } from "./runPool.js";
@@ -56,21 +57,162 @@ const VERSION: string = (() => {
return "unknown";
})();
// Prevent silent server death from unhandled async errors
process.on("unhandledRejection", (err) => {
process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
});
process.on("uncaughtException", (err) => {
process.stderr.write(`[context-mode] uncaughtException: ${err?.message ?? err}\n`);
});
// Prevent silent MCP server death from unhandled async errors.
//
// Guarded for plugin-native OpenCode/Kilo imports (#574): when server.js is
// imported only to reuse the ctx_* tool registry, these handlers would become
// process-wide OpenCode/Kilo host handlers. In Node, adding an
// `uncaughtException` listener changes default crash behavior, so only the
// standalone MCP process may install them.
if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
process.on("unhandledRejection", (err) => {
process.stderr.write(`[context-mode] unhandledRejection: ${err}\n`);
});
process.on("uncaughtException", (err) => {
process.stderr.write(`[context-mode] uncaughtException: ${err?.message ?? err}\n`);
});
}
const runtimes = detectRuntimes();
const available = getAvailableLanguages(runtimes);
const server = new McpServer({
export const server = new McpServer({
name: "context-mode",
version: VERSION,
});
export interface RegisteredCtxTool {
name: string;
config: Record<string, unknown>;
handler: (args: Record<string, unknown>) => Promise<unknown> | unknown;
}
export const REGISTERED_CTX_TOOLS: RegisteredCtxTool[] = [];
export function shouldSuppressMcpToolsForNativePluginHost(
opts: { embedded?: string; platform?: PlatformId; settings?: Record<string, unknown> | null } = {},
): boolean {
const embedded = opts.embedded ?? process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS;
if (embedded === "1") return false;
const platform = opts.platform ?? detectPlatform().platform;
if (platform !== "opencode" && platform !== "kilo") return false;
const settings = opts.settings ?? readNativePluginHostSettings(platform);
return settingsHasContextModePlugin(settings) && settingsHasLegacyContextModeMcp(settings);
}
function stripJsonComments(str: string): string {
let out = "";
let inString = false;
let escaped = false;
let inBlockComment = false;
for (let i = 0; i < str.length; i++) {
const c = str[i];
const next = str[i + 1];
if (inBlockComment) {
if (c === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (escaped) {
out += c;
escaped = false;
continue;
}
if (c === "\\") {
out += c;
escaped = inString;
continue;
}
if (c === '"') {
inString = !inString;
out += c;
continue;
}
if (!inString && c === "/" && next === "/") {
while (i < str.length && str[i] !== "\n") i++;
if (i < str.length) out += "\n";
continue;
}
if (!inString && c === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
out += c;
}
return out
.replace(/,(\s*[}\]])/g, "$1");
}
function readNativePluginHostSettings(platform: PlatformId): Record<string, unknown> | null {
const base = platform === "kilo" ? "kilo" : "opencode";
const paths = [
resolve(`${base}.json`),
resolve(`${base}.jsonc`),
resolve(`.${base}`, `${base}.json`),
resolve(`.${base}`, `${base}.jsonc`),
join(homedir(), ".config", base, `${base}.json`),
join(homedir(), ".config", base, `${base}.jsonc`),
];
for (const p of paths) {
try {
if (!existsSync(p)) continue;
return JSON.parse(stripJsonComments(readFileSync(p, "utf8"))) as Record<string, unknown>;
} catch { /* try next config path */ }
}
return null;
}
function settingsHasContextModePlugin(settings: Record<string, unknown> | null | undefined): boolean {
const plugins = settings?.plugin;
return Array.isArray(plugins) && plugins.some((p) => typeof p === "string" && p.includes("context-mode"));
}
function settingsHasLegacyContextModeMcp(settings: Record<string, unknown> | null | undefined): boolean {
const mcp = settings?.mcp;
return !!(
mcp &&
typeof mcp === "object" &&
!Array.isArray(mcp) &&
Object.prototype.hasOwnProperty.call(mcp, "context-mode")
);
}
const suppressMcpToolsForNativePluginHost = shouldSuppressMcpToolsForNativePluginHost();
const originalRegisterTool = server.registerTool.bind(server);
(server as unknown as { registerTool: (...args: unknown[]) => unknown }).registerTool = (...args: unknown[]) => {
const [name, config, handler] = args as [
string,
Record<string, unknown>,
(toolArgs: Record<string, unknown>) => Promise<unknown> | unknown,
];
if (suppressMcpToolsForNativePluginHost) return undefined;
REGISTERED_CTX_TOOLS.push({ name, config, handler });
return (originalRegisterTool as unknown as (...callArgs: unknown[]) => unknown)(...args);
};
type ToolContextOverride = { projectDir: string; sessionId?: string };
const projectDirOverride = new AsyncLocalStorage<ToolContextOverride>();
export async function withProjectDirOverride<T>(
projectDir: string | ToolContextOverride,
fn: () => Promise<T>,
): Promise<T> {
const ctx = typeof projectDir === "string" ? { projectDir } : projectDir;
return projectDirOverride.run(ctx, fn);
}
// Register empty prompts/resources handlers so MCP clients don't get -32601 (#168).
// OpenCode calls listPrompts()/listResources() unconditionally — the error can poison
// the SDK transport layer, causing subsequent listTools() calls to fail permanently.
@@ -96,6 +238,11 @@ writeFileSync(
CM_FS_PRELOAD,
`(function(){var __cm_fs=0;process.on('exit',function(){if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch(e){}});try{var f=require('fs');var ors=f.readFileSync;f.readFileSync=function(){var r=ors.apply(this,arguments);if(Buffer.isBuffer(r))__cm_fs+=r.length;else if(typeof r==='string')__cm_fs+=Buffer.byteLength(r);return r;};}catch(e){}})();\n`,
);
// In the stdio MCP path, main() also removes this file during graceful
// shutdown. Plugin-native OpenCode/Kilo imports skip main() (#574), so
// register a top-level best-effort cleanup too to avoid leaking preload
// snippets under /tmp when the host process exits.
process.on("exit", () => { try { unlinkSync(CM_FS_PRELOAD); } catch { /* best effort */ } });
// Lazy singleton — no DB overhead unless index/search is used
let _store: ContentStore | null = null;
@@ -109,6 +256,9 @@ let _store: ContentStore | null = null;
* legacy unattributed rows readable.
*/
export function currentAttribution(): { sessionId?: string } | undefined {
const override = projectDirOverride.getStore();
if (override?.sessionId) return { sessionId: override.sessionId };
// CLAUDE_SESSION_ID env var is NOT propagated to MCP servers (only to hooks).
// Cross-adapter resolution: every adapter (15 of them) sets *_PROJECT_DIR env
// and writes session_events via hooks. Read the most-recent session_id from
@@ -260,6 +410,9 @@ function getSessionDir(): string {
* that don't set their own env var (Cursor, OpenClaw, Codex, Kiro, Zed).
*/
function getProjectDir(): string {
const override = projectDirOverride.getStore();
if (override) return override.projectDir;
// Delegated to the shared resolver so the env-var chain rejects plugin
// install paths (set by a prior MCP boot's start.mjs after `/ctx-upgrade`)
// and prefers the shell-set PWD before the chdir'd cwd. v1.0.115 adds
@@ -3955,7 +4108,9 @@ async function main() {
}
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
}
+73
View File
@@ -226,6 +226,79 @@ describe("OpenCodeAdapter", () => {
rmSync(root, { recursive: true, force: true });
});
it("configureAllHooks removes legacy context-mode MCP block for plugin-only mode (#574)", () => {
const root = mkdtempSync(join(tmpdir(), "opencode-adapter-"));
const dir = join(root, "project");
const home = join(root, "home");
const conf = join(home, ".config", "opencode");
const file = join(conf, "opencode.json");
const src = resolve(process.cwd(), "src", "adapters", "opencode", "index.ts");
const tsx = resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
mkdirSync(dir, { recursive: true });
mkdirSync(conf, { recursive: true });
writeFileSync(file, JSON.stringify({
mcp: {
"context-mode": {
type: "local",
command: ["context-mode"],
},
other: { type: "local", command: ["other"] },
},
plugin: ["context-mode"],
}, null, 2) + "\n");
const run = spawnSync(
process.execPath,
[
tsx,
"-e",
`import { OpenCodeAdapter } from ${JSON.stringify(src)};const a=new OpenCodeAdapter();console.log(JSON.stringify(a.configureAllHooks('/tmp/plugin')))`,
],
{ cwd: dir, env: env(home), encoding: "utf-8" },
);
expect(run.status).toBe(0);
expect(JSON.parse(run.stdout)).toContain("Removed legacy context-mode MCP block (plugin-native tools)");
expect(JSON.parse(readFileSync(file, "utf-8"))).toEqual({
mcp: { other: { type: "local", command: ["other"] } },
plugin: ["context-mode"],
});
rmSync(root, { recursive: true, force: true });
});
it("validateHooks warns when a legacy mcp.context-mode block remains after upgrade (#574)", () => {
const root = mkdtempSync(join(tmpdir(), "opencode-adapter-"));
const dir = join(root, "project");
const home = join(root, "home");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "opencode.json"), JSON.stringify({
mcp: { "context-mode": { type: "local", command: ["context-mode"] } },
plugin: ["context-mode"],
}, null, 2) + "\n");
const prevHome = process.env.HOME;
const prevUserProfile = process.env.USERPROFILE;
Object.assign(process.env, env(home));
const cwd = process.cwd();
process.chdir(dir);
try {
const results = new OpenCodeAdapter().validateHooks("/tmp/plugin");
expect(results).toContainEqual(expect.objectContaining({
check: "Legacy MCP registration",
status: "warn",
fix: expect.stringContaining("removes only mcp.context-mode"),
}));
} finally {
process.chdir(cwd);
if (prevHome !== undefined) process.env.HOME = prevHome;
else delete process.env.HOME;
if (prevUserProfile !== undefined) process.env.USERPROFILE = prevUserProfile;
else delete process.env.USERPROFILE;
rmSync(root, { recursive: true, force: true });
}
});
it("readSettings prioritizes config with context-mode plugin", () => {
const root = mkdtempSync(join(tmpdir(), "opencode-adapter-"));
const dir = join(root, "project");
+6 -9
View File
@@ -244,12 +244,10 @@ describe("MCPStdioClient — handles EPIPE when writing to child stdin", () => {
});
});
// Slice 6 — respawn after idle self-shutdown (#583)
// Slice 6 — respawn after MCP child exit (#583)
//
// Regression: in v1.0.132 the MCP server gained an idle self-shutdown
// (#565/#568, lifecycle.ts). When the Pi-spawned child exits cleanly
// after CONTEXT_MODE_IDLE_TIMEOUT_MS of inactivity, Pi keeps the
// previously-registered tool handles, but the bridge client has
// Regression: when the Pi-spawned child exits cleanly while Pi keeps the
// previously-registered tool handles, the bridge client has
// `exited=true` and every subsequent request rejects with
// "MCP server has exited". The user sees a permanently broken set of
// `ctx_*` tools until they restart Pi.
@@ -257,12 +255,11 @@ describe("MCPStdioClient — handles EPIPE when writing to child stdin", () => {
// Fix: when `callTool()` is invoked on an exited client, respawn the
// MCP child + re-`initialize()` transparently before issuing the call,
// so already-registered Pi tools recover on the very next use.
describe("MCPStdioClient — respawns after idle self-shutdown (#583)", () => {
describe("MCPStdioClient — respawns after MCP child exit (#583)", () => {
it("re-spawns the child when callTool is invoked after exit, and the call succeeds", async () => {
// Fake MCP server: handles initialize, tools/list, tools/call.
// On its FIRST process incarnation it exits cleanly after the first
// tools/call — mirroring lifecycle.ts gracefulShutdown(0) firing on
// idle. A marker file on disk distinguishes the original child from
// tools/call — mirroring a clean MCP child shutdown. A marker file on disk distinguishes the original child from
// the respawned one so the second incarnation stays alive.
const markerPath = join(scratch, "first-incarnation-marker");
const fakePath = join(scratch, "exit-after-call.mjs");
@@ -290,7 +287,7 @@ describe("MCPStdioClient — respawns after idle self-shutdown (#583)", () => {
} else if (msg.method === "tools/call") {
callCount++;
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: "pong-pid-" + process.pid }] } }) + "\\n");
// First incarnation: mimic idle self-shutdown after one call.
// First incarnation: mimic clean MCP child shutdown after one call.
if (isFirst && callCount === 1) {
writeFileSync(MARKER, "1");
setTimeout(() => process.exit(0), 10);
+49
View File
@@ -4752,3 +4752,52 @@ describe("v1.0.134 SLICE A — cross-adapter currentAttribution session DB fallb
}
});
});
test("withProjectDirOverride carries native plugin session id into currentAttribution (#574)", async () => {
const { withProjectDirOverride, currentAttribution } = await import("../../src/server.js");
const projectDir = mkdtempSync(join(tmpdir(), "native-plugin-attr-proj-"));
try {
const attr = await withProjectDirOverride(
{ projectDir, sessionId: "opencode-session-override" },
async () => currentAttribution(),
);
expect(attr).toEqual({ sessionId: "opencode-session-override" });
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
test("OpenCode/Kilo legacy MCP child suppresses ctx_* tool registration while embedded plugin import keeps it", async () => {
const { shouldSuppressMcpToolsForNativePluginHost } = await import("../../src/server.js");
const legacySettings = {
plugin: ["context-mode"],
mcp: { "context-mode": { type: "local", command: ["context-mode"] } },
};
expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "opencode", settings: legacySettings })).toBe(true);
expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "kilo", settings: legacySettings })).toBe(true);
expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "opencode", settings: { plugin: ["context-mode"] } })).toBe(false);
expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "opencode", embedded: "1", settings: legacySettings })).toBe(false);
expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "claude-code", embedded: undefined })).toBe(false);
});
test("OpenCode legacy MCP suppression parses JSONC URLs without stripping // inside strings", async () => {
const { shouldSuppressMcpToolsForNativePluginHost } = await import("../../src/server.js");
const dir = mkdtempSync(join(tmpdir(), "opencode-jsonc-url-"));
const cwd = process.cwd();
try {
writeFileSync(join(dir, "opencode.jsonc"), `{
// Keep this URL intact; a naive /\\/\\/.*/ stripper corrupts it.
"endpoint": "https://example.com/api",
"plugin": ["context-mode"],
"mcp": {
"context-mode": { "type": "local", "command": ["context-mode"] },
"other": { "type": "local", "command": ["other"] }
}
}\n`);
process.chdir(dir);
expect(shouldSuppressMcpToolsForNativePluginHost({ platform: "opencode" })).toBe(true);
} finally {
process.chdir(cwd);
rmSync(dir, { recursive: true, force: true });
}
});
+73
View File
@@ -11,7 +11,9 @@ import "./setup-home";
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
// ── Test helpers ──────────────────────────────────────────
@@ -83,6 +85,77 @@ describe("ContextModePlugin", () => {
expect(typeof plugin["chat.message"]).toBe("function");
});
it("registers all ctx_* tools natively via the plugin tool map (#574)", async () => {
const plugin = await createTestPlugin(join(tempDir, "factory-native-tools"));
expect(plugin).toHaveProperty("tool");
expect(Object.keys(plugin.tool ?? {}).sort()).toEqual([
"ctx_batch_execute",
"ctx_doctor",
"ctx_execute",
"ctx_execute_file",
"ctx_fetch_and_index",
"ctx_index",
"ctx_insight",
"ctx_purge",
"ctx_search",
"ctx_stats",
"ctx_upgrade",
]);
});
it("ctx_stats native plugin tool executes without an MCP child (#574 smoke)", async () => {
const projectDir = join(tempDir, "factory-native-tool-exec");
const plugin = await createTestPlugin(projectDir);
const result = await plugin.tool!.ctx_stats.execute({}, {
sessionID: "session-native-tool",
messageID: "msg-native-tool",
agent: "test-agent",
directory: projectDir,
worktree: projectDir,
abort: new AbortController().signal,
metadata: () => {},
ask: (() => ({}) as any) as any,
});
const output = typeof result === "string" ? result : result.output;
expect(output).toContain("context-mode");
});
it("native tool registry import does not leak process handlers or embedded env into OpenCode host", () => {
const childHome = mkdtempSync(join(tmpdir(), "opencode-plugin-side-effects-"));
try {
const tsx = resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
const pluginPath = pathToFileURL(resolve(process.cwd(), "src", "adapters", "opencode", "plugin.ts")).href;
const script = `
(async () => {
const { ContextModePlugin } = await import(${JSON.stringify(pluginPath)});
const before = {
unhandled: process.listenerCount("unhandledRejection"),
uncaught: process.listenerCount("uncaughtException"),
};
await ContextModePlugin({ directory: ${JSON.stringify(childHome)}, client: { app: { log: async () => {} } } });
const after = {
unhandled: process.listenerCount("unhandledRejection"),
uncaught: process.listenerCount("uncaughtException"),
embedded: process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS ?? null,
};
console.log(JSON.stringify({ before, after }));
})().catch((err) => { console.error(err); process.exit(1); });
`;
const run = spawnSync(process.execPath, [tsx, "-e", script], {
cwd: process.cwd(),
env: { ...process.env, HOME: childHome, USERPROFILE: childHome },
encoding: "utf-8",
});
expect(run.status, run.stderr).toBe(0);
const result = JSON.parse(run.stdout.trim());
expect(result.after.unhandled).toBe(result.before.unhandled);
expect(result.after.uncaught).toBe(result.before.uncaught);
expect(result.after.embedded).toBeNull();
} finally {
rmSync(childHome, { recursive: true, force: true });
}
});
it("does not write AGENTS.md routing instructions on startup", async () => {
const projectDir = join(tempDir, "factory-startup-routing");
mkdirSync(projectDir, { recursive: true });