mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-02 04:14:38 +08:00
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:
co-authored by
Ousama Ben Younes
parent
7d5b52948f
commit
09efefdc4a
@@ -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
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"context-mode": {
|
||||
"type": "local",
|
||||
"command": ["context-mode"]
|
||||
}
|
||||
},
|
||||
"plugin": ["context-mode"]
|
||||
"plugin": [
|
||||
"context-mode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user