#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createRequire } from "node:module"; import { existsSync, unlinkSync, readdirSync, readFileSync, writeFileSync, writeSync, renameSync, rmSync, mkdirSync, cpSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs"; import { spawnSync, type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process"; import { join, dirname, resolve, sep, isAbsolute } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir, tmpdir, cpus, platform } 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"; import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs, type SearchResult, type IndexResult } from "./store.js"; import { composeFetchCacheKey } from "./fetch-cache.js"; import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, readToolPermissionPatterns, evaluateFilePath, evaluateProjectContainment, } from "./security.js"; import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js"; import { classifyNonZeroExit } from "./exit-classify.js"; import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js"; import { charSafePrefix } from "./truncate.js"; import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js"; import { purgeSession } from "./session/purge.js"; import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js"; import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js"; import { appendRetrievalBytes } from "./session/retrieval-marker.js"; import { searchAllSources } from "./search/unified.js"; import { buildCtxSearchInputSchema, CTX_SEARCH_SHARED_MODE, resolveProjectScope, } from "./search/ctx-search-schema.js"; import { FloodGuard } from "./search/flood-guard.js"; import { buildNodeCommand, type HookAdapter, type PlatformId, isInProcessPluginPlatform } from "./adapters/types.js"; import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js"; import { parseCodexContextModePluginRoot } from "./adapters/codex/index.js"; import { getHookScriptPaths } from "./util/hook-config.js"; import { stripJsonComments } from "./util/jsonc.js"; import { resolveClaudeConfigDir } from "./util/claude-config.js"; import { resolveProjectDir } from "./util/project-dir.js"; import { loadDatabase } from "./db-base.js"; import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getConversationWindowStats, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, pricePerToken } from "./session/analytics.js"; const __pkg_dir = dirname(fileURLToPath(import.meta.url)); const VERSION: string = (() => { for (const rel of ["../package.json", "./package.json"]) { const p = resolve(__pkg_dir, rel); if (existsSync(p)) { try { return JSON.parse(readFileSync(p, "utf8")).version; } catch {} } } return "unknown"; })(); function getPackageRoot(): string { return existsSync(resolve(__pkg_dir, "package.json")) ? __pkg_dir : dirname(__pkg_dir); } function resolveCodexRuntimePluginRoot(fallbackRoot: string): string { try { const probe = process.platform === "win32" ? spawnSync("cmd.exe", ["/d", "/s", "/c", "codex plugin list"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, }) : spawnSync("codex", ["plugin", "list"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, }); if (probe.status !== 0) return fallbackRoot; const runtimeRoot = parseCodexContextModePluginRoot(String(probe.stdout)); if (runtimeRoot && existsSync(resolve(runtimeRoot, ".codex-plugin", "hooks.json"))) { return runtimeRoot; } } catch { // Best effort only. Non-Codex hosts and older Codex builds may not expose // plugin list; keep the package-root fallback for those environments. } return fallbackRoot; } function getRuntimeAwarePackageRoot(platformId?: PlatformId): string { const packageRoot = getPackageRoot(); return platformId === "codex" ? resolveCodexRuntimePluginRoot(packageRoot) : packageRoot; } // 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) => { try { writeSync(2, `[context-mode] uncaughtException: ${err?.message ?? err}\n`); } finally { process.exit(1); } }); } const runtimes = detectRuntimes(); const available = getAvailableLanguages(runtimes); export const server = new McpServer({ name: "context-mode", version: VERSION, }); export interface RegisteredCtxTool { name: string; config: Record; handler: (args: Record) => Promise | unknown; } export const REGISTERED_CTX_TOOLS: RegisteredCtxTool[] = []; export function shouldSuppressMcpToolsForNativePluginHost( opts: { embedded?: string; platform?: PlatformId; settings?: Record | 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 readNativePluginHostSettings(platform: PlatformId): Record | 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; } catch { /* try next config path */ } } return null; } function settingsHasContextModePlugin(settings: Record | 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 | 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(); /** * Issue #623 — surface why ctx_* tools/list is empty on suppressed legacy MCP * children. When a user upgrades OpenCode/Kilo from v1.0.136 → v1.0.137+ without * running `context-mode upgrade`, their opencode.json still has BOTH the legacy * mcp.context-mode block AND the plugin entry. The plugin path registers the * tools natively, but the legacy MCP child runs in parallel and used to expose * duplicate tools — v1.0.137 suppressed those duplicates. The suppression was * silent, leaving any MCP client that inspected the child via tools/list with * an empty list and no diagnostic. Emit one stderr line per process so an * operator running the child directly (or any non-plugin MCP host) sees the * exact reason and the `context-mode upgrade` fix. * * Exported for test (suppression-diagnostic regression guard). */ let __suppressionDiagnosticEmitted = false; export function emitSuppressionDiagnostic( opts: { platform?: string; write?: (chunk: string) => void } = {}, ): void { if (__suppressionDiagnosticEmitted) return; __suppressionDiagnosticEmitted = true; const write = opts.write ?? ((c: string) => { process.stderr.write(c); }); const platform = opts.platform ?? "opencode/kilo"; write( `[context-mode] ctx_* tools/list intentionally empty on this MCP child: ` + `legacy mcp.context-mode block coexists with plugin: ["context-mode"] in ` + `${platform}.json — plugin-native tools are the supported path (#623). ` + `Run \`context-mode upgrade\` to remove the legacy block (preserves other ` + `MCP servers).\n` ); } /** Test-only: reset the one-shot emission flag so suites can re-exercise. */ export function __resetSuppressionDiagnosticForTests(): void { __suppressionDiagnosticEmitted = false; } /** * Issue #637 — register an explicit empty `tools/list` handler on the McpServer. * * Background: when `suppressMcpToolsForNativePluginHost` is true, every * `server.registerTool()` call is short-circuited (returns `undefined` above). * The MCP SDK only installs the SDK-default `tools/list` handler when at least * one `registerTool()` reaches `setToolRequestHandlers()` internally * (mcp.js:56-67). Suppressing every registration leaves `tools/list` * unregistered, and the framework's RPC layer answers it with * `-32601 "Method not found"`. * * The reporter of #637 (SquirrelRat) inspected the suppressed child via * `tools/list` and read the JSON-RPC error as "the plugin never registers any * ctx_* tools" — when in fact the plugin DOES register all 11 tools natively * (verified at `src/adapters/opencode/plugin.ts:469` and * `tests/opencode-plugin.test.ts:88`). The misleading -32601 is the seed of * the #637 perception. * * This helper installs an explicit handler that returns `{tools: []}` — a * spec-compliant empty list. Paired with the existing #623 stderr diagnostic, * an operator now sees: * - wire response: `{tools: []}` (matches expectation, no JSON-RPC error) * - stderr: `[context-mode] ctx_* tools/list intentionally empty… (#623)` * * Idempotent: throws inside SDK if called twice on the same server because * `assertCanSetRequestHandler` (mcp.js:60) rejects duplicate registrations; * we therefore install the SDK's default tool handlers FIRST (via a no-op * registerTool of a fake tool, immediately removed) only if needed. To keep * the public surface minimal, we just call `server.server.setRequestHandler` * directly — that is the same low-level call used for prompts/resources at * server.ts:259-261 and avoids the SDK guard entirely. * * Exported for test (#637 in-memory regression guard). */ export function registerEmptyToolsListHandler(target: McpServer = server): void { target.server.registerCapabilities({ tools: { listChanged: false } }); target.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] })); } 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, (toolArgs: Record) => Promise | unknown, ]; if (suppressMcpToolsForNativePluginHost) { emitSuppressionDiagnostic(); return undefined; } const wrappedHandler = wrapToolHandler(name, handler); REGISTERED_CTX_TOOLS.push({ name, config, handler: wrappedHandler }); args[2] = wrappedHandler; return (originalRegisterTool as unknown as (...callArgs: unknown[]) => unknown)(...args); }; function wrapToolHandler( name: string, handler: (toolArgs: Record) => Promise | unknown, ): (toolArgs: Record) => Promise { return async (toolArgs: Record) => { // #854: mark a tool call in-flight so the bridge-child idle reaper never // shuts the server down mid-execution during a long ctx_execute/batch that // emits no further inbound messages. Symmetric end in finally (success+error). noteRequestStart(); try { return await handler(toolArgs); } catch (err) { const result = storageErrorResult(err); if (result) { try { return trackResponse(name, result); } catch (trackErr) { if (trackErr instanceof StorageDirectoryError) return result; throw trackErr; } } throw err; } finally { noteRequestEnd(); } }; } // Issue #637 — when suppression is active, install the empty tools/list handler // once at module-init time so the suppressed MCP child responds with // `{tools: []}` instead of JSON-RPC `-32601 Method not found`. Pair with the // #623 stderr diagnostic that explains WHY the list is empty. Skipped for the // embedded plugin-import path because the embedded process is not the stdio // MCP child an operator would inspect — it lives inside the OpenCode/Kilo // host and never speaks JSON-RPC over stdio. if (suppressMcpToolsForNativePluginHost && process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") { registerEmptyToolsListHandler(server); } type ToolContextOverride = { projectDir: string; sessionId?: string }; const projectDirOverride = new AsyncLocalStorage(); export async function withProjectDirOverride( projectDir: string | ToolContextOverride, fn: () => Promise, ): Promise { 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. import { ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; server.server.registerCapabilities({ prompts: { listChanged: false }, resources: { listChanged: false } }); server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] })); server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] })); server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] })); // ── Strict-client (Gemini function-calling) schema compatibility ────────────── // Gemini's function-calling API — used by Antigravity CLI (`agy`) and Gemini CLI // — rejects JSON Schema `const` and `additionalProperties`. A rejected parameter // schema makes the host SILENTLY DROP that tool from the model's function list, // so the agent never sees our ctx_* tools and falls back to hand-rolling the MCP // protocol through its Bash tool. Sanitize the EMITTED tools/list schema: // • `const: X` → `enum: [X]` — an identical single-value constraint // • drop `additionalProperties` — advisory only; every ctx_* handler parses // args with Zod (which strips unknown keys server-side), so removing it // changes no validation and no call behavior. // Both transforms are behavior-preserving for every other client (Claude Code, // Copilot, Cursor, …): `const` and a one-value `enum` are equivalent, and no // model sends undeclared properties. Only the wire schema changes — never // validation or how any tool is invoked. export function sanitizeSchemaForStrictClients(node: unknown): unknown { if (Array.isArray(node)) return node.map(sanitizeSchemaForStrictClients); if (node === null || typeof node !== "object") return node; const out: Record = {}; for (const [key, value] of Object.entries(node as Record)) { if (key === "additionalProperties") continue; if (key === "const") { out.enum = [value]; continue; } out[key] = sanitizeSchemaForStrictClients(value); } return out; } // Wrap the SDK-installed tools/list handler so its generated schemas pass through // the sanitizer above. Best-effort by design: if the MCP SDK's internals shift, // the original handler is left untouched (no regression — strict clients stay as // they were, every other client unaffected). Must run AFTER all registerTool() // calls so the SDK's default tools/list handler already exists. export function installStrictClientSchemaCompat(target: McpServer = server): void { try { const low = target.server as unknown as { _requestHandlers?: Map Promise>; }; const original = low._requestHandlers?.get("tools/list"); if (typeof original !== "function") return; target.server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => { const result = (await original(req as unknown, extra as unknown)) as | { tools?: Array<{ inputSchema?: unknown }> } | undefined; if (result && Array.isArray(result.tools)) { for (const tool of result.tools) { if (!tool || tool.inputSchema == null) continue; try { tool.inputSchema = sanitizeSchemaForStrictClients(tool.inputSchema); } catch { /* leave this tool's schema unchanged */ } } } return result as never; }); } catch { /* best-effort — never break tools/list */ } } const executor = new PolyglotExecutor({ runtimes, projectRoot: () => getProjectDir(), }); // ───────────────────────────────────────────────────────── // FS read tracking preload for ctx_batch_execute // ───────────────────────────────────────────────────────── // NODE_OPTIONS is denied by the executor's #buildSafeEnv (security). // Instead, we inject it as an inline shell env prefix in each batch command. // This temp file is loaded via --require when batch commands spawn Node processes. const CM_FS_PRELOAD = join(tmpdir(), `cm-fs-preload-${process.pid}.js`); 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; /** * Build the FK-attribution object passed to every ContentStore.index*() call * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on * the current session — eventId stays undefined because MCP tool invocations * are not paired with PostToolUse event rows at index time (the hook fires * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps * 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 // THIS project's session DB. Works for claude-code/cursor/gemini-cli/codex/ // kiro/opencode/zed/kilo/openclaw/qwen-code/vscode-copilot/jetbrains-copilot/ // omp/pi/antigravity — no adapter-specific transcript path required. const sessionId = process.env.CLAUDE_SESSION_ID ?? resolveSessionIdFromSessionDB(); if (!sessionId) return undefined; return { sessionId }; } let __cachedSessionId: { sid: string; checkedAt: number } | undefined; /** v1.0.134 SLICE A: opts injection for testability. Production callers pass nothing. */ export function resolveSessionIdFromSessionDB(opts?: { projectDir?: string; sessionsDir?: string; bypassCache?: boolean; }): string | undefined { // 2s cache — ctx_fetch_and_index can fire 5+ chunks/sec; DB open cost adds up. const now = Date.now(); if (!opts?.bypassCache && __cachedSessionId && now - __cachedSessionId.checkedAt < 2000) { return __cachedSessionId.sid; } try { const projectDir = opts?.projectDir ?? process.env.CLAUDE_PROJECT_DIR ?? process.env.CONTEXT_MODE_PROJECT_DIR; if (!projectDir) return undefined; const sessionsDir = opts?.sessionsDir ?? getSessionDir(); const dbPath = resolveSessionDbPath({ projectDir, sessionsDir }); if (!existsSync(dbPath)) return undefined; const Database = loadDatabase(); const db = new Database(dbPath, { readonly: true, fileMustExist: true }); try { const row = db.prepare( "SELECT session_id FROM session_events ORDER BY created_at DESC LIMIT 1" ).get() as { session_id?: string } | undefined; const sid = row?.session_id; if (sid) __cachedSessionId = { sid, checkedAt: now }; return sid; } finally { try { db.close(); } catch { /* best-effort */ } } } catch { return undefined; } } /** * Auto-index session events files written by SessionStart hook. * Scans ~/.claude/context-mode/sessions/ for *-events.md files. * CLAUDE_PROJECT_DIR is NOT available to MCP servers — only to hooks — * so we glob-scan instead of computing a specific hash. * Files are consumed (deleted) after indexing to prevent double-indexing. * Called on every getStore() — readdirSync is sub-millisecond when no files match. */ function maybeIndexSessionEvents(store: ContentStore): void { try { const sessionsDir = getSessionDir(); if (!existsSync(sessionsDir)) return; const files = readdirSync(sessionsDir).filter(f => f.endsWith("-events.md")); for (const file of files) { const filePath = join(sessionsDir, file); try { store.index({ path: filePath, source: "session-events", attribution: currentAttribution() }); unlinkSync(filePath); } catch { /* best-effort per file */ } } } catch { /* best-effort — session continuity never blocks tools */ } } // ── Platform-aware paths ────────────────────────────────────────────────── // The adapter (stored after MCP handshake) is the canonical source for // platform-specific paths. All session DB paths go through it — no // hardcoded configDir detection in tool handlers. let _detectedAdapter: HookAdapter | null = null; /** * Resolve the Claude Code config root, honoring `CLAUDE_CONFIG_DIR` (incl. * leading `~`) before falling back to `~/.claude`. Mirrors * `hooks/session-helpers.mjs::resolveConfigDir` and * `ClaudeCodeAdapter.getConfigDir` so the pre-detection path agrees with * hooks/adapter on where Claude Code session data lives. See issue #453. * * Issue #460 round-3: delegates to the canonical util so empty/whitespace * env values fall back instead of poisoning downstream `join()` calls. */ async function getDiagnosticAdapter(): Promise { if (_detectedAdapter) return _detectedAdapter; try { const { getAdapter } = await import("./adapters/detect.js"); const signal = detectPlatform(); return await getAdapter(signal.platform); } catch { return null; } } /** * Get the platform-specific sessions directory from the detected adapter. * Falls back to the detected platform config root before adapter detection. */ function getDefaultSessionDir(): string { if (_detectedAdapter) return _detectedAdapter.getSessionDir(); // Pre-detection path (race window before MCP `initialize` completes): // call detectPlatform() (sync, env-var-based) and look up segments via // getSessionDirSegments() (sync map, no adapter instantiation). This keeps // non-Claude platforms from spilling sessions into ~/.claude/. For Claude // Code/Codex (single-segment roots), reroute through their config-dir // contracts so the pre-detection window does not split-state with hooks. try { const signal = detectPlatform(); const segments = getSessionDirSegments(signal.platform); if (segments) { return resolveDefaultSessionDir({ configDir: join(...segments), configDirEnv: configDirEnvForSessionSegments(segments), }); } } catch { /* fall through to claude fallback */ } return resolveDefaultSessionDir({ configDir: ".claude", configDirEnv: "CLAUDE_CONFIG_DIR" }); } function configDirEnvForSessionSegments(segments: string[]): string | undefined { if (segments.length === 1 && segments[0] === ".claude") return "CLAUDE_CONFIG_DIR"; if (segments.length === 1 && segments[0] === ".codex") return "CODEX_HOME"; return undefined; } function getSessionDir(): string { return ensureWritableStorageDir(resolveSessionStorageDir(getDefaultSessionDir)); } /** * Project directory detection across supported platforms. * * Priority: * 1. Platform-specific env var (set by host IDE before MCP server spawn) * 2. CONTEXT_MODE_PROJECT_DIR (set by start.mjs for ALL platforms — universal) * 3. process.cwd() (last resort) * * CONTEXT_MODE_PROJECT_DIR guarantees correct projectDir even for platforms * that don't set their own env var (Cursor, OpenClaw, Codex, Kiro, Zed). */ export 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 // the Claude Code transcript heuristic — read `cwd` from the most-recently- // modified `~/.claude/projects//.jsonl` to recover the // real project dir when MCP was launched from a non-project cwd (desktop- // app launch, /ctx-upgrade respawn). See src/util/project-dir.ts. // // Issue #521 (v1.0.119): the transcript heuristic ONLY applies on Claude // Code. Other platforms (Cursor, OpenCode, Codex, ...) either have no // transcript at that path or use a different schema without `cwd`. Worse, // a Cursor user who also runs Claude Code would pick up the most-recently- // modified Claude Code session's cwd — wrong project entirely. Gate the // path on detected platform so non-Claude hosts skip the heuristic and // fall through to PWD/cwd cleanly. // // The Claude heuristic must also be fresh. Hosts such as Pi can be // misdetected as Claude Code solely because ~/.claude exists; without a // freshness guard an old Claude transcript can globally hijack ctx shell cwd // after reboot. Active Claude sessions update their transcript as the user // interacts, so stale transcripts should fall through to PWD/cwd. // // Issue #545 (v1.0.124): pass strictPlatform for ALL adapters so the // env-var cascade is built ALGORITHMICALLY from the platform's own // workspace vars + universal escape hatch — foreign workspace vars (e.g. // CLAUDE_PROJECT_DIR leaked into Pi's MCP child env from the user's shell) // cannot win, regardless of cascade order. start.mjs intentionally does // NOT pass strictPlatform — host detection is unreliable at the entrypoint // and the legacy literal cascade is preserved there for semver safety. let transcriptsRoot: string | undefined; let strictPlatform: PlatformId | undefined; let codexHome: string | undefined; try { const detected = detectPlatform().platform; strictPlatform = detected; if (detected === "claude-code") { transcriptsRoot = join(homedir(), ".claude", "projects"); } // Issue #45 — Codex publishes no workspace env var, so the resolver // reads `meta.cwd` from the most-recently-modified session.jsonl under // `${codexHome}/sessions/`. Wire codexHome at the call site so the // resolver can be exercised under test without process-level mutation. if (detected === "codex") { codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex"); } } catch { /* detection failure — leave undefined, resolver uses legacy cascade */ } return resolveProjectDir({ env: process.env, cwd: process.cwd(), pwd: process.env.PWD, transcriptsRoot, transcriptMaxAgeMs: 5 * 60 * 1000, strictPlatform, codexHome, }); } /** * Resolve a possibly-relative path against the project directory (full env cascade), * not the MCP server's process.cwd(). MCP server is spawned by the host and its cwd * is unrelated to where the user is working. */ function resolveProjectPath(filePath: string): string { return isAbsolute(filePath) ? filePath : resolve(getProjectDir(), filePath); } /** * Resolve the per-project SessionDB path. Delegates to * {@link resolveSessionDbPath} so casing-only variants of the same * physical worktree on macOS / Windows hit ONE DB, not two — and any * pre-existing legacy raw-casing DB gets migrated in place on first * resolve. Linux is a no-op. */ function getSessionDbPath(): string { return resolveSessionDbPath({ projectDir: getProjectDir(), sessionsDir: getSessionDir(), }); } /** * Compute a per-project, per-platform persistent path for the ContentStore. * Derives content dir from the adapter's session dir so each platform * has its own isolated FTS5 DB — no cross-platform data sharing. * * Layout: ~//context-mode/content/.db * e.g. ~/.claude/context-mode/content/87c28c41ddb64d38.db * ~/.cursor/context-mode/content/87c28c41ddb64d38.db */ function getStorePath(): string { const dir = ensureWritableStorageDir(resolveContentStorageDir(getDefaultSessionDir)); // Delegate to resolveContentStorePath: same case-fold + one-shot legacy // rename behavior as resolveSessionDbPath. On macOS / Windows, an // existing legacy raw-casing FTS5 db (with -wal/-shm sidecars) is // migrated in place on first call. On Linux it's a no-op. return resolveContentStorePath({ projectDir: getProjectDir(), contentDir: dir }); } function getStore(): ContentStore { if (!_store) { // Content DB cleanup on fresh start is handled by SessionStart hook. // Server just opens whatever DB exists (or creates new if hook deleted it). const dbPath = getStorePath(); _store = new ContentStore(dbPath); // Wire deny-policy hook: store re-checks the Read deny list before // re-reading any file_path during auto-refresh. Catches policy edits // made after a file was originally indexed. See #442 round-3. _store.setDenyChecker((filePath: string) => { try { const projectDir = getProjectDir(); const denyGlobs = readToolDenyPatterns("Read", projectDir); const r = evaluateFilePath( filePath, denyGlobs, process.platform === "win32", projectDir, ); return r.denied; } catch { // Fail-closed for refresh: skip on error rather than re-read. return true; } }); // One-time startup cleanup: remove stale content DBs (>14 days) try { const contentDir = dirname(getStorePath()); cleanupStaleContentDBs(contentDir, 14); _store.cleanupStaleSources(14); // Also clean legacy shared dir from before platform isolation const legacyDir = join(homedir(), ".context-mode", "content"); if (existsSync(legacyDir)) cleanupStaleContentDBs(legacyDir, 0); } catch { /* best-effort */ } // Also clean old PID-based DBs from migration cleanupStaleDBs(); } maybeIndexSessionEvents(_store); return _store; } // ───────────────────────────────────────────────────────── // Session stats — track context consumption per tool // ───────────────────────────────────────────────────────── const sessionStats = { calls: {} as Record, bytesReturned: {} as Record, bytesIndexed: 0, bytesSandboxed: 0, // network I/O consumed inside sandbox (never enters context) cacheHits: 0, cacheMisses: 0, // ctx_fetch_and_index calls that bypassed the TTL cache cacheBytesSaved: 0, // bytes avoided by TTL cache hits sessionStart: Date.now(), }; type ToolResult = { content: Array<{ type: "text"; text: string }>; isError?: boolean; }; function storageErrorResult(err: unknown): ToolResult | null { if (!(err instanceof StorageDirectoryError)) return null; return { content: [{ type: "text", text: formatStorageDirectoryError(err) }], isError: true, }; } // ── Version outdated warning ────────────────────────────────────────────── // Non-blocking npm check at startup. trackResponse prepends warning // using a burst cadence: 3 warnings → 1h silent → 3 warnings → repeat. let _latestVersion: string | null = null; let _warningBurstCount = 0; let _lastBurstStart = 0; const VERSION_BURST_SIZE = 3; const VERSION_SILENT_MS = 60 * 60 * 1000; // 1 hour async function fetchLatestVersion(): Promise { return new Promise((res) => { const req = httpsRequest( "https://registry.npmjs.org/context-mode/latest", { headers: { Connection: "close" } }, (resp) => { let raw = ""; resp.on("data", (chunk: Buffer) => { raw += chunk; }); resp.on("end", () => { try { const data = JSON.parse(raw) as { version?: string }; res(data.version ?? "unknown"); } catch { res("unknown"); } }); }, ); req.on("error", () => res("unknown")); req.setTimeout(5000, () => { req.destroy(); res("unknown"); }); req.end(); }); } function getUpgradeHint(): string { const name = _detectedAdapter?.name; if (name === "Claude Code") return "/ctx-upgrade"; if (name === "OpenClaw") return "npm run install:openclaw"; if (name === "Pi") return "npm run build"; return "npm update -g context-mode"; } function semverNewer(a: string, b: string): boolean { const pa = a.split(".").map(Number); const pb = b.split(".").map(Number); for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false; } return false; } function isOutdated(): boolean { if (!_latestVersion || _latestVersion === "unknown") return false; return semverNewer(_latestVersion, VERSION); } function shouldShowVersionWarning(): boolean { if (!isOutdated()) return false; const now = Date.now(); // Start of a new burst? if (_warningBurstCount >= VERSION_BURST_SIZE) { if (now - _lastBurstStart < VERSION_SILENT_MS) return false; // still silent _warningBurstCount = 0; // silence over, reset burst } if (_warningBurstCount === 0) _lastBurstStart = now; _warningBurstCount++; return true; } // ── Self-heal Layer 2: Mid-session registry heal (anthropics/claude-code#46915) ── // Runs once on first tool call. If Claude Code auto-updated the registry mid-session, // hooks break because CLAUDE_PLUGIN_ROOT points to a deleted directory. We create a // symlink from the broken path to our actual directory so hooks recover. let _cacheHealDone = false; function healCacheMidSession(): void { if (_cacheHealDone) return; _cacheHealDone = true; try { // Issue #460 round-3: honor $CLAUDE_CONFIG_DIR so users who relocate // their CC config root don't have plugin cache healing operate against // the wrong tree (and silently miss dangling-symlink cleanup). const claudeRoot = resolveClaudeConfigDir(); const ipPath = resolve(claudeRoot, "plugins", "installed_plugins.json"); if (!existsSync(ipPath)) return; const ip = JSON.parse(readFileSync(ipPath, "utf-8")); const cacheRoot = resolve(claudeRoot, "plugins", "cache"); // Issue #795: canonicalize cacheRoot so the traversal guard works when // ~/.claude is a symlink to another volume. path.resolve() does not // dereference symlinks, so installPath values stored as physical paths // (e.g. /Volumes/SSD/.../plugins/cache/...) would fail the startsWith // check against a symlink-path cacheRoot (/Users/me/.claude/...). // realpathSync follows the symlink chain to the canonical location. let cacheRootCanon: string; try { cacheRootCanon = realpathSync(cacheRoot); } catch { cacheRootCanon = cacheRoot; } // Plugin root: build/ for tsc, plugin root for bundle const pluginRoot = getPackageRoot(); for (const [key, entries] of Object.entries((ip.plugins ?? {}) as Record>)) { if (key !== "context-mode@context-mode") continue; for (const entry of entries) { const rp = entry.installPath; if (!rp || existsSync(rp)) continue; // Path traversal guard (canonical comparison — see #795) if (!resolve(rp).startsWith(cacheRootCanon + sep)) continue; // Remove dangling symlink try { if (lstatSync(rp).isSymbolicLink()) unlinkSync(rp); } catch {} const parent = dirname(rp); if (!existsSync(parent)) mkdirSync(parent, { recursive: true }); if (existsSync(pluginRoot)) { symlinkSync(pluginRoot, rp, process.platform === "win32" ? "junction" : undefined); } } } } catch { /* best effort */ } } function trackResponse(toolName: string, response: ToolResult): ToolResult { // #854: a response is activity too — refresh the bridge-child idle clock so a // chatty/streaming call keeps its server alive even between inbound frames. noteMcpActivity(); // Mid-session cache heal — one-shot, first tool call healCacheMidSession(); // Prepend version outdated warning if needed if (shouldShowVersionWarning() && response.content.length > 0) { const hint = getUpgradeHint(); response.content[0].text = `⚠️ context-mode v${VERSION} outdated → v${_latestVersion} available. Upgrade: ${hint}\n\n` + response.content[0].text; } const bytes = response.content.reduce( (sum, c) => sum + Buffer.byteLength(c.text), 0, ); sessionStats.calls[toolName] = (sessionStats.calls[toolName] || 0) + 1; sessionStats.bytesReturned[toolName] = (sessionStats.bytesReturned[toolName] || 0) + bytes; // Persist a sidecar JSON snapshot for the statusline — read at ~3-5 Hz by // bin/statusline.mjs (and any external dashboard) so they don't have to // open the SQLite database. Throttled inside persistStats() (500ms) so // it's safe to call on every response. persistStats(); // Persist to SessionDB so counters survive process restart, --continue, // upgrade. Re-introduces the write path 4742160 added and b392c2f dropped. // setImmediate keeps this off the response hot path; the helper itself // is best-effort (never throws). setImmediate(() => persistToolCallCounter(getSessionDbPath(), toolName, bytes)); // D2 Phase 5/7 — sandbox-execute event emission. Tracks the bytes the // user actually saw from sandboxed runs so getRealBytesStats() can // replace the conservative `events × 256` estimate. Best-effort and // off the hot path, same shape as persistToolCallCounter above. if ( toolName === "ctx_execute" || toolName === "ctx_execute_file" || toolName === "ctx_batch_execute" ) { setImmediate(() => emitSandboxExecuteEvent({ sessionDbPath: getSessionDbPath(), toolName, bytesReturned: bytes, }) ); } // Retrieval ("With context-mode") bridge — ctx_search / ctx_fetch_and_index // response bytes are the kept-out content the model paid to access. The // PostToolUse hook never fires for the plugin's OWN MCP tools, so the // hook-side extractMcpToolCall can never see these calls (bytes_retrieved // was 0/124454 in prod). Drop the count into a marker keyed by the session // DB; the next ordinary-tool PostToolUse consumes it and emits a forwardable // bytes_retrieved event. Off the hot path; never throws. if (toolName === "ctx_search" || toolName === "ctx_fetch_and_index") { setImmediate(() => appendRetrievalBytes(getSessionDbPath(), bytes)); } return response; } function trackIndexed(bytes: number, source: string = "unknown"): void { sessionStats.bytesIndexed += bytes; persistStats(); // D2 Phase 5/7 — index-write event emission. `bytes_avoided` because // these are bytes that would have flooded context if the user had // Read'd the source instead of indexing. if (bytes > 0) { setImmediate(() => emitIndexWriteEvent({ sessionDbPath: getSessionDbPath(), source, bytesAvoided: bytes, }) ); } } // ───────────────────────────────────────────────────────── // Stats persistence — written after every tool call so // external readers (status line scripts, dashboards, hooks) // can see real-time savings without spawning an MCP client. // ───────────────────────────────────────────────────────── const STATS_PERSIST_THROTTLE_MS = 500; // Schema version for the persisted stats payload (~/.claude/context-mode/sessions/stats-*.json). // Bump when a field is added/renamed/removed. Statusline reads `schemaVersion ?? 0` and warns when // it sees a future schema, so legacy bundles degrade gracefully on upgrade rather than silently // rendering missing fields (PR #401 architect review P1.3). // v2: added tokens_saved_lifetime + dollars_saved_lifetime. const STATS_SCHEMA_VERSION = 2; // pricePerToken() intentionally NOT defined here — single source in // src/session/analytics.ts re-exported above. (P1.1 — pricing constant dedup, // PR #401 architect + ops 2-vote convergence.) const LIFETIME_REFRESH_MS = 30_000; // Matches the conversion factor in src/session/analytics.ts renderBottomLine: // ~1KB per session event ÷ 4 bytes/token = 256 tokens/event. const TOKENS_PER_EVENT = 256; let _lastStatsPersist = 0; let _lifetimeCache: { tokens: number; computedAt: number } | undefined; /** * Resolve the per-session stats file path. * * The session id mirrors the Claude Code adapter contract * (`pid-`), so a status line script can derive * the same id from `$PPID` without coupling to MCP. */ // CLAUDE_SESSION_ID flows from the hosting process (Claude Code, pi, etc.) // straight into a path.join, and path.join collapses ".." into the result, // so a host env CLAUDE_SESSION_ID=../../evil writes "stats-evil.json" two // levels above statsDir. The env var is not under direct MCP-tool-caller // control, but in CI / multi-tenant contexts where the host env is partly // influenceable this is an arbitrary-write primitive within the MCP server // process's filesystem permissions. Constrain to a UUID-shaped charset // before splicing into the stats filename. const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/; function sanitizeSessionId(raw: string): string { return SESSION_ID_RE.test(raw) ? raw : `pid-${process.ppid}`; } function getStatsFilePath(): string { const raw = process.env.CLAUDE_SESSION_ID || `pid-${process.ppid}`; const sessionId = sanitizeSessionId(raw); const statsDir = ensureWritableStorageDir(resolveStatsStorageDir(getDefaultSessionDir)); return join(statsDir, `stats-${sessionId}.json`); } function persistStats(): void { const now = Date.now(); if (now - _lastStatsPersist < STATS_PERSIST_THROTTLE_MS) return; _lastStatsPersist = now; try { const totalReturned = Object.values(sessionStats.bytesReturned).reduce( (a, b) => a + b, 0, ); const totalCalls = Object.values(sessionStats.calls).reduce( (a, b) => a + b, 0, ); const keptOut = sessionStats.bytesIndexed + sessionStats.bytesSandboxed + sessionStats.cacheBytesSaved; const totalProcessed = keptOut + totalReturned; const reductionPct = totalProcessed > 0 ? Math.round((1 - totalReturned / totalProcessed) * 100) : 0; const tokensSaved = Math.round(keptOut / 4); // Lifetime savings — cached separately because getLifetimeStats() scans // disk (per-project SessionDBs + auto-memory dirs) and is too expensive // for the 500ms persist throttle. Refresh every 30s; the statusline // doesn't need second-by-second lifetime accuracy. let lifetimeTokens = _lifetimeCache?.tokens ?? 0; if (!_lifetimeCache || now - _lifetimeCache.computedAt > LIFETIME_REFRESH_MS) { try { const life = getLifetimeStats({ sessionsDir: getSessionDir() }); lifetimeTokens = (life?.totalEvents ?? 0) * TOKENS_PER_EVENT; _lifetimeCache = { tokens: lifetimeTokens, computedAt: now }; } catch { // best-effort — keep stale cache or 0 } } const payload = { schemaVersion: STATS_SCHEMA_VERSION, version: VERSION, updated_at: now, session_start: sessionStats.sessionStart, uptime_ms: now - sessionStats.sessionStart, total_calls: totalCalls, bytes_returned: totalReturned, bytes_indexed: sessionStats.bytesIndexed, bytes_sandboxed: sessionStats.bytesSandboxed, cache_hits: sessionStats.cacheHits, cache_bytes_saved: sessionStats.cacheBytesSaved, kept_out: keptOut, total_processed: totalProcessed, reduction_pct: reductionPct, tokens_saved: tokensSaved, // statusline-facing $ values — pre-computed at the current per-token // rate (dynamic when PI_CONTEXT_MODE_PRICE_OUTPUT_PER_TOKEN is set by a // Pi host; Opus $15/1M otherwise). Resolved on every persist via // pricePerToken() so the env override picks up without an MCP restart. dollars_saved_session: +(tokensSaved * pricePerToken()).toFixed(2), tokens_saved_lifetime: lifetimeTokens, dollars_saved_lifetime: +(lifetimeTokens * pricePerToken()).toFixed(2), by_tool: Object.fromEntries( Object.keys({ ...sessionStats.calls, ...sessionStats.bytesReturned }).map( (t) => [ t, { calls: sessionStats.calls[t] || 0, bytes: sessionStats.bytesReturned[t] || 0, }, ], ), ), }; const filePath = getStatsFilePath(); const tmpPath = `${filePath}.tmp`; writeFileSync(tmpPath, JSON.stringify(payload)); renameSync(tmpPath, filePath); } catch { // best-effort — never break tool calls because of stats persistence } } // ============================================================================== // Security: server-side deny firewall // ============================================================================== /** * Check a shell command against Bash deny patterns. * Returns an error ToolResult if denied, or null if allowed. */ function checkDenyPolicy( command: string, toolName: string, ): ToolResult | null { try { const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR); const result = evaluateCommandDenyOnly(command, policies); if (result.decision === "deny") { return trackResponse(toolName, { content: [{ type: "text" as const, text: `Command blocked by security policy: matches deny pattern ${result.matchedPattern}`, }], isError: true, }); } } catch { // Security check failed — allow through (fail-open for server, // hooks are the primary enforcement layer) } return null; } /** * Check non-shell code for shell-escape calls against deny patterns. */ function checkNonShellDenyPolicy( code: string, language: string, toolName: string, ): ToolResult | null { try { const commands = extractShellCommands(code, language); if (commands.length === 0) return null; const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR); for (const cmd of commands) { const result = evaluateCommandDenyOnly(cmd, policies); if (result.decision === "deny") { return trackResponse(toolName, { content: [{ type: "text" as const, text: `Command blocked by security policy: embedded shell command "${cmd}" matches deny pattern ${result.matchedPattern}`, }], isError: true, }); } } } catch { // Fail-open } return null; } /** * Issue #852 — project-boundary containment for `ctx_execute_file`. * * The harness sandbox (Claude Code, etc.) cannot inspect MCP input params, so a * user approving a `ctx_execute_file` call cannot see that its `path` escapes * the workspace. This guard refuses a `path` that resolves outside the project * root (absolute escape, `../` traversal, or symlink-out), restoring the * boundary the host believes it is enforcing. * * Escape hatch — NO bespoke opt-out env. A deliberate out-of-project read is * expressed in the SAME host config the user already maintains: a * `permissions.allow` rule like `Read(/var/log/**)`. This reuses the exact * mechanism Claude Code uses to whitelist a path outside its sandbox, so the * grant lives in one place and stays meaningful instead of rotting into a * context-mode-only env flag nobody sets. * * Fail-open on resolver failure (consistent with the other deny checks): if the * project root cannot be resolved, containment evaluates as "inside" and the * path is allowed through rather than spuriously blocking legitimate work. */ function checkProjectBoundary( filePath: string, toolName: string, ): ToolResult | null { try { const projectDir = getProjectDir(); const allowGlobs = readToolPermissionPatterns("Read", "allow", projectDir); const verdict = evaluateProjectContainment(filePath, projectDir, allowGlobs); if (verdict.allowed) return null; return trackResponse(toolName, { content: [{ type: "text" as const, text: `File access blocked: "${filePath}" resolves outside the project root ` + `(${projectDir}). context-mode confines ${toolName} to the workspace so it ` + `cannot be used to bypass the host's sandbox/permission controls (issue #852). ` + `To intentionally process a file outside the project, add a host allow rule, ` + `e.g. "permissions": { "allow": ["Read(${filePath})"] } in your settings.`, }], isError: true, }); } catch { // Fail-open — resolver failure must not block legitimate in-project work. } return null; } /** * Check a file path against Read deny patterns. * Returns an error ToolResult if denied, or null if allowed. */ function checkFilePathDenyPolicy( filePath: string, toolName: string, ): ToolResult | null { try { const projectDir = getProjectDir(); const denyGlobs = readToolDenyPatterns("Read", projectDir); const result = evaluateFilePath( filePath, denyGlobs, process.platform === "win32", projectDir, ); if (result.denied) { return trackResponse(toolName, { content: [{ type: "text" as const, text: `File access blocked by security policy: path matches Read deny pattern ${result.matchedPattern}`, }], isError: true, }); } } catch { // Fail-open } return null; } // Build description dynamically based on detected runtimes const langList = available.join(", "); const bunNote = hasBunRuntime() ? " (Bun detected — JS/TS runs 3-5x faster)" : ""; // ───────────────────────────────────────────────────────── // Helper: smart snippet extraction — returns windows around // matching query terms instead of dumb truncation // // When `highlighted` is provided (from FTS5 `highlight()` with // STX/ETX markers), match positions are derived from the markers. // This is the authoritative source — FTS5 uses the exact same // tokenizer that produced the BM25 match, so stemmed variants // like "configuration" matching query "configure" are found // correctly. Falls back to indexOf on raw terms when highlighted // is absent (non-FTS codepath). // ───────────────────────────────────────────────────────── const STX = "\x02"; const ETX = "\x03"; /** * Parse FTS5 highlight markers to find match positions in the * original (marker-free) text. Returns character offsets into the * stripped content where each matched token begins. */ export function positionsFromHighlight(highlighted: string): number[] { const positions: number[] = []; let cleanOffset = 0; let i = 0; while (i < highlighted.length) { if (highlighted[i] === STX) { // Record position of this match in the clean text positions.push(cleanOffset); i++; // skip STX // Advance through matched text until ETX while (i < highlighted.length && highlighted[i] !== ETX) { cleanOffset++; i++; } if (i < highlighted.length) i++; // skip ETX } else { cleanOffset++; i++; } } return positions; } /** Strip STX/ETX markers to recover original content. */ function stripMarkers(highlighted: string): string { return highlighted.replaceAll(STX, "").replaceAll(ETX, ""); } export function extractSnippet( content: string, query: string, maxLen = 1500, highlighted?: string, ): string { if (content.length <= maxLen) return content; // Derive match positions from FTS5 highlight markers when available const positions: number[] = []; if (highlighted) { for (const pos of positionsFromHighlight(highlighted)) { positions.push(pos); } } // Fallback: indexOf on raw query terms (non-FTS codepath) if (positions.length === 0) { const terms = query .toLowerCase() .split(/\s+/) .filter((t) => t.length > 2); const lower = content.toLowerCase(); for (const term of terms) { let idx = lower.indexOf(term); while (idx !== -1) { positions.push(idx); idx = lower.indexOf(term, idx + 1); } } } // No matches at all — return prefix if (positions.length === 0) { return content.slice(0, maxLen) + "\n…"; } // Sort positions, merge overlapping windows positions.sort((a, b) => a - b); const WINDOW = 300; const windows: Array<[number, number]> = []; for (const pos of positions) { const start = Math.max(0, pos - WINDOW); const end = Math.min(content.length, pos + WINDOW); if (windows.length > 0 && start <= windows[windows.length - 1][1]) { windows[windows.length - 1][1] = end; } else { windows.push([start, end]); } } // Collect windows until maxLen const parts: string[] = []; let total = 0; for (const [start, end] of windows) { if (total >= maxLen) break; const part = content.slice(start, Math.min(end, start + (maxLen - total))); parts.push( (start > 0 ? "…" : "") + part + (end < content.length ? "…" : ""), ); total += part.length; } return parts.join("\n\n"); } export type BatchQueryScope = "batch" | "global"; export function formatBatchQueryResults( store: ContentStore, queries: string[], source: string, maxOutput = 80 * 1024, scope: BatchQueryScope = "batch", ): string[] { const sections: string[] = []; let outputSize = 0; // When scope is "global", searchWithFallback receives `undefined` for the // source filter, which makes it query the entire persistent index instead // of only the chunks just produced by this batch's commands. Default // remains "batch" to preserve the historical behavior. const searchSource = scope === "global" ? undefined : source; for (const query of queries) { if (outputSize > maxOutput) { sections.push(`## ${query}\n(output cap reached — use ctx_search(queries: ["${query}"]) for details)\n`); continue; } const results = store.searchWithFallback(query, 3, searchSource, undefined, "exact"); sections.push(`## ${query}`); sections.push(""); if (results.length > 0) { for (const result of results) { const snippet = extractSnippet(result.content, query, 3000, result.highlighted); sections.push(`### ${result.title}`); sections.push(snippet); sections.push(""); outputSize += snippet.length + result.title.length; } continue; } sections.push("No matching sections found."); sections.push(""); } if (scope === "global") { sections.push(`\n> **Scope:** Queries searched the entire persistent index (query_scope: "global").`); } else { sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\` or call ctx_batch_execute with \`query_scope: "global"\`.`); } return sections; } // ───────────────────────────────────────────────────────── // batch_execute runner — used by ctx_batch_execute handler // ───────────────────────────────────────────────────────── export interface BatchCommand { label: string; command: string; } export interface BatchRunResult { outputs: string[]; timedOut: boolean; } export interface BatchRunOptions { /** * Total budget (concurrency=1, shared) or per-command (concurrency>1). * When `undefined`, no server-side timer fires — the MCP host's RPC * timeout governs (Issue #406). */ timeout: number | undefined; concurrency: number; nodeOptsPrefix: string; cwd?: string; onFsBytes?: (bytes: number) => void; } interface BatchExecutor { execute(input: { language: "shell"; code: string; timeout: number | undefined; cwd?: string }): Promise<{ stdout: string; timedOut?: boolean }>; } function quotePosixSingle(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } function quotePowerShellSingle(value: string): string { return `'${value.replace(/'/g, "''")}'`; } export function buildBatchNodeOptionsPrefix(shellPath: string, preloadPath: string): string { const option = `--require ${preloadPath}`; const shell = shellPath.toLowerCase(); const base = shell.split(/[\\/]/).pop() ?? shell; if (shell.includes("powershell") || shell.includes("pwsh")) { return `$env:NODE_OPTIONS=${quotePowerShellSingle(option)}; `; } if (base === "cmd" || base === "cmd.exe") { return `set "NODE_OPTIONS=${option.replace(/"/g, '""')}" && `; } return `NODE_OPTIONS=${quotePosixSingle(option)} `; } /** * Per-section budget for the echoed `$ ` line so a 50KB heredoc * payload cannot dominate the response body. The full command always reaches * the executor — only the echo is clipped (Issues #717 + #736). */ const COMMAND_ECHO_MAX = 500; function truncateCommandForEcho(command: string): string { const cleaned = command.replace(/\s+/g, " ").trim(); if (cleaned.length <= COMMAND_ECHO_MAX) return cleaned; return cleaned.slice(0, COMMAND_ECHO_MAX) + "…"; } /** * Default execution timeout (ms) applied ONLY under Antigravity CLI (`agy`). * agy does not enforce an MCP RPC timeout, so a ctx_execute with a runaway or * blocking script hangs forever — the host never kills it and the user must * interrupt. Every other host enforces its own RPC timeout, so we keep the * no-server-timer behavior there (Issue #406 — long builds need an unbounded * run). A caller can still pass an explicit `timeout` to override on any host. */ export const AGY_DEFAULT_EXEC_TIMEOUT_MS = 120_000; export function resolveExecTimeout(timeout: number | undefined): number | undefined { if (timeout !== undefined) return timeout; // Only agy gets a default — every other host enforces its own RPC timeout, so // keep the unbounded behavior there. Detected via the env the agy bundle pins // (CONTEXT_MODE_PLATFORM=antigravity-cli). Tunable via CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS. if (detectPlatform().platform !== "antigravity-cli") return undefined; const override = Number(process.env.CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS); return Number.isFinite(override) && override > 0 ? override : AGY_DEFAULT_EXEC_TIMEOUT_MS; } /** * Per-call budget for the source-code echo prepended by `ctx_execute` and * `ctx_execute_file` (Issues #717 + #736). The full code always reaches the * sandbox — only the echo is clipped so massive payloads don't dominate * the response. Multi-line preserved (unlike command echo) so the user * sees the actual program shape. */ const CODE_ECHO_MAX = 2000; function truncateCodeForEcho(code: string): string { if (code.length <= CODE_ECHO_MAX) return code; return code.slice(0, CODE_ECHO_MAX) + "\n… (truncated)"; } /** * Build the source-code preamble surfaced before tool stdout. Provenance * survives in indexed chunks (FTS5 sees the fenced block) so later * ctx_search hits remember what ran. */ function buildExecuteEcho(language: string, code: string, path?: string): string { const header = path ? `path=${path}\n` : ""; const fenced = `\`\`\`${language}\n${truncateCodeForEcho(code)}\n\`\`\``; return `${header}${fenced}\n\n`; } function formatCommandOutput(label: string, command: string, raw: string, onFsBytes?: (bytes: number) => void): string { let output = raw || "(no output)"; const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g); let cmdFsBytes = 0; for (const m of fsMatches) cmdFsBytes += parseInt(m[1]); if (cmdFsBytes > 0) { onFsBytes?.(cmdFsBytes); output = output.replace(/__CM_FS__:\d+\n?/g, ""); } // Echo the executed command below the section heading so per-chunk // indexed content retains provenance for later ctx_search hits // (Issues #717 + #736). const echoed = truncateCommandForEcho(command); return `# ${label}\n\n$ ${echoed}\n\n${output}\n`; } function combineExecOutput(result: { stdout?: string; stderr?: string }): string { const stdout = result.stdout || ""; const stderr = result.stderr || ""; if (!stderr) return stdout; if (!stdout) return stderr; return `${stdout}${stdout.endsWith("\n") ? "" : "\n"}${stderr}`; } /** * Execute batch commands. concurrency=1 preserves the legacy serial path * (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs * commands concurrently with at most N in flight; each command receives the * full timeout, output is collated by input index, and per-command timeouts * record `(timed out)` blocks without skipping siblings. */ export async function runBatchCommands( commands: BatchCommand[], opts: BatchRunOptions, executor: BatchExecutor, ): Promise { const { timeout, concurrency, nodeOptsPrefix, cwd, onFsBytes } = opts; if (concurrency <= 1) { // Serial path — shared timeout budget, cascading skip on timeout. // When `timeout` is undefined, no shared budget is enforced; each // command runs to completion (Issue #406). const outputs: string[] = []; const startTime = Date.now(); let timedOut = false; for (let i = 0; i < commands.length; i++) { const cmd = commands[i]; let perCmdTimeout: number | undefined; if (timeout !== undefined) { const elapsed = Date.now() - startTime; const remaining = timeout - elapsed; if (remaining <= 0) { outputs.push(`# ${cmd.label}\n\n(skipped — batch timeout exceeded)\n`); timedOut = true; continue; } perCmdTimeout = remaining; } const result = await executor.execute({ language: "shell", code: `${nodeOptsPrefix}${cmd.command}`, timeout: perCmdTimeout, cwd, }); outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes)); if (result.timedOut) { timedOut = true; for (let j = i + 1; j < commands.length; j++) { outputs.push(`# ${commands[j].label}\n\n(skipped — batch timeout exceeded)\n`); } break; } } return { outputs, timedOut }; } // Parallel path — delegated to the shared runPool primitive. // Each job returns { output, timedOut }; runPool handles in-flight cap, // throw isolation (Promise.allSettled semantics), and order preservation. const jobs: PoolJob<{ output: string; timedOut: boolean }>[] = commands.map((cmd) => ({ run: async () => { const result = await executor.execute({ language: "shell", code: `${nodeOptsPrefix}${cmd.command}`, timeout, cwd, }); // Always route partial output through formatCommandOutput so __CM_FS__ // markers are stripped + counted, even when the command timed out. const formatted = formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes); const output = result.timedOut ? formatted.replace(/\n$/, "") + `\n(timed out after ${timeout ?? "?"}ms)\n` : formatted; return { output, timedOut: !!result.timedOut }; }, })); const { settled } = await runPool(jobs, { concurrency }); const outputs: string[] = new Array(commands.length); let timedOut = false; for (let i = 0; i < settled.length; i++) { const r = settled[i]; if (r.status === "fulfilled") { outputs[i] = r.value.output; if (r.value.timedOut) timedOut = true; } else { // Isolated executor throw (spawn EAGAIN, ENOMEM, EMFILE, …) — siblings keep running. const message = r.reason instanceof Error ? r.reason.message : String(r.reason); outputs[i] = `# ${commands[i].label}\n\n(executor error: ${message})\n`; } } return { outputs, timedOut }; } // ───────────────────────────────────────────────────────── // Tool: execute // ───────────────────────────────────────────────────────── server.registerTool( "ctx_execute", { // #852: surface code execution in the host approval prompt's title (the // only server-controlled field the MCP permission UI renders besides args). title: "Run code in a sandbox (executes the supplied code)", // #846: runs arbitrary code in a sandbox with full network access. annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, description: `Run code in a sandboxed subprocess.${bunNote} Languages: ${langList}. Think-in-Code — the core philosophy: the bytes your code processes never enter your conversation memory; only what you console.log() does. Reading a 700 KB log directly means 700 KB of your remaining reasoning capacity gets spent on raw bytes. Running code over that same log in this sandbox and printing a 3 KB summary leaves you with 697 KB of capacity for the actual work. Concrete shape — analyze 47 source files without reading any of them: ctx_execute(language: "javascript", code: \` const fs = require('fs'); const files = fs.readdirSync('src').filter(f => f.endsWith('.ts')); files.forEach(f => { const lines = fs.readFileSync('src/'+f,'utf8').split('\\\\n').length; console.log(f + ': ' + lines + ' lines'); }); \`) // 47 files analyzed, 15,314 LoC summarized — output ~3.6 KB instead of 47 Read() calls = ~700 KB. WHEN: - You intend to derive an answer FROM data (filter, count, aggregate, parse, compare, transform) — do the derivation in code and print only the answer - Output shape or size cannot be predicted before execution (recursive finds, repo-wide greps, list endpoints, query results, log scans) - You would otherwise read raw output and then mentally compute — that compute belongs here, in code, where its inputs stay out of your conversation - You need to keep a long-running process alive (dev server, watcher, daemon) — pass \`background: true\` to detach on timeout instead of killing the process - The output may legitimately be large but you only want recall-by-topic later — pass an \`intent\` string; outputs over ~5KB are auto-indexed into the knowledge base and only the section titles + previews come back, retrievable via ctx_search WHEN NOT: - Single observational command whose entire short output you intend to consume verbatim (whoami, pwd, git status on a clean tree) — Bash is simpler - File mutations (Edit/Write) or navigation (cd/ls) — Bash is the right surface - You already know the output is one short fixed line and you want to read it as-is RETURNS: Only what your code prints. Wrap risky calls in try/catch — uncaught errors go to stderr and may leak more than intended. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout; use ctx_search(queries: [...]) to drill into specific sections. EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('npm test', {encoding:'utf8', stdio:['ignore','pipe','pipe']}); console.log(out.split('\\\\n').filter(l => /(FAIL|✗|×|Error:|Tests +.*(failed|passed))/i.test(l)).slice(0, 60).join('\\\\n'))") EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_process').execSync('gh issue list --json number,title --limit 100', {encoding:'utf8'}); const hooks = JSON.parse(out).filter(i => /hook|routing/i.test(i.title)); console.log(\`\${hooks.length} hook-related issues\`)")`, inputSchema: z.object({ language: z .enum([ "javascript", "typescript", "python", "shell", "ruby", "go", "rust", "php", "perl", "r", "elixir", "csharp", ]) .describe("Runtime language"), code: z .string() .describe( "Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), IO.puts (Elixir), or Console.WriteLine (C#) to output a summary to context.", ), timeout: z .coerce.number() .optional() .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs (which is the right layer for this policy). Pass an explicit value for long-running builds (Gradle/Maven/SBT)."), // background: wrapped in coerceBoolean preprocessor so the literal // strings "true"/"false" arriving from OpenCode's native plugin // bridge (and several LLM providers' tool-call JSON) parse as the // boolean the handler expects. z.coerce.boolean() is unsafe here — // Boolean("false") is true. Fixes #627. background: z .preprocess(coerceBoolean, z.boolean()) .optional() .default(false) .describe("Keep process running after timeout (for servers/daemons). Returns partial output without killing the process. IMPORTANT: Do NOT add setTimeout/self-close timers in background scripts — the process must stay alive until the timeout detaches it. For server+fetch patterns, prefer putting both server and fetch in ONE ctx_execute call instead of using background."), cwd: z .string() .optional() .describe("Optional working directory for shell commands. Non-shell languages still execute from their sandbox temp directory."), intent: z .string() .optional() .describe( "What you're looking for in the output. When provided and output is large (>5KB), " + "indexes output into knowledge base and returns section titles + previews — not full content. " + "Use ctx_search(queries: [...]) to retrieve specific sections. Example: 'failing tests', 'HTTP 500 errors'." + "\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary.", ), }), }, async ({ language, code, timeout, background, cwd, intent }) => { // Security: deny-only firewall if (language === "shell") { const denied = checkDenyPolicy(code, "execute"); if (denied) return denied; } else { const denied = checkNonShellDenyPolicy(code, language, "execute"); if (denied) return denied; } try { // For JS/TS: wrap in async IIFE with fetch + http/https interceptors to track network bytes let instrumentedCode = code; if (language === "javascript" || language === "typescript") { // Wrap user code in a closure that shadows CJS require with http/https interceptor. // globalThis.require does NOT work because CJS require is module-scoped, not global. // The closure approach (function(__cm_req){ var require=...; })(require) correctly // shadows the CJS require for all code inside, including __cm_main(). instrumentedCode = ` // FS read instrumentation — count bytes read via fs.readFileSync/readFile let __cm_fs=0; process.on('exit',()=>{if(__cm_fs>0)try{process.stderr.write('__CM_FS__:'+__cm_fs+'\\n')}catch{}}); (function(){ try{ var f=typeof require!=='undefined'?require('fs'):null; if(!f)return; 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;}; var orf=f.readFile; if(orf)f.readFile=function(){var a=Array.from(arguments),cb=a.pop();orf.apply(this,a.concat([function(e,d){if(!e&&d){if(Buffer.isBuffer(d))__cm_fs+=d.length;else if(typeof d==='string')__cm_fs+=Buffer.byteLength(d);}cb(e,d);}]));}; }catch{} })(); let __cm_net=0; // Report network bytes on process exit — works with both promise and callback patterns. // process.on('exit') fires after all I/O completes, unlike .finally() which fires // when __cm_main() resolves (immediately for callback-based http.get without await). process.on('exit',()=>{if(__cm_net>0)try{process.stderr.write('__CM_NET__:'+__cm_net+'\\n')}catch{}}); ;(function(__cm_req){ // Intercept globalThis.fetch const __cm_f=globalThis.fetch; globalThis.fetch=async(...a)=>{const r=await __cm_f(...a); try{const cl=r.clone();const b=await cl.arrayBuffer();__cm_net+=b.byteLength}catch{} return r}; // Shadow CJS require with http/https network tracking. const __cm_hc=new Map(); const __cm_hm=new Set(['http','https','node:http','node:https']); function __cm_wf(m,origFn){return function(...a){ const li=a.length-1; if(li>=0&&typeof a[li]==='function'){const oc=a[li];a[li]=function(res){ res.on('data',function(c){__cm_net+=c.length});oc(res);};} const req=origFn.apply(m,a); const oOn=req.on.bind(req); req.on=function(ev,cb,...r){ if(ev==='response'){return oOn(ev,function(res){ res.on('data',function(c){__cm_net+=c.length});cb(res); },...r);} return oOn(ev,cb,...r); }; return req; }} var require=__cm_req?function(id){ const m=__cm_req(id); if(!__cm_hm.has(id))return m; const k=id.replace('node:',''); if(__cm_hc.has(k))return __cm_hc.get(k); const w=Object.create(m); if(typeof m.get==='function')w.get=__cm_wf(m,m.get); if(typeof m.request==='function')w.request=__cm_wf(m,m.request); __cm_hc.set(k,w);return w; }:__cm_req; if(__cm_req){if(__cm_req.resolve)require.resolve=__cm_req.resolve; if(__cm_req.cache)require.cache=__cm_req.cache;} async function __cm_main(){ ${code} } __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nsetInterval(()=>{},2147483647);' : ''} })(typeof require!=='undefined'?require:null);`; } const effTimeout = resolveExecTimeout(timeout); const result = await executor.execute({ language, code: instrumentedCode, timeout: effTimeout, background, cwd }); // Echo the executed source code before stdout so users can audit // and tooling can block command patterns (Issues #717 + #736). // Built from the user-supplied `code`, NOT the instrumented variant. const echo = buildExecuteEcho(language, code); // Parse sandbox network metrics from stderr const netMatch = result.stderr?.match(/__CM_NET__:(\d+)/); if (netMatch) { sessionStats.bytesSandboxed += parseInt(netMatch[1]); // Clean the metric line from stderr result.stderr = result.stderr.replace(/\n?__CM_NET__:\d+\n?/g, ""); } // Parse sandbox FS read metrics from stderr const fsMatch = result.stderr?.match(/__CM_FS__:(\d+)/); if (fsMatch) { sessionStats.bytesSandboxed += parseInt(fsMatch[1]); result.stderr = result.stderr.replace(/\n?__CM_FS__:\d+\n?/g, ""); } if (result.timedOut) { const partialOutput = result.stdout?.trim(); if (result.backgrounded && partialOutput) { // Background mode: process is still running, return partial output as success return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `${echo}${partialOutput}\n\n_(process backgrounded after ${effTimeout}ms — still running)_`, }, ], }); } if (partialOutput) { // Timeout with partial output — return as success with note return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `${echo}${partialOutput}\n\n_(timed out after ${effTimeout}ms — partial output shown above)_`, }, ], }); } return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `${echo}Execution timed out after ${effTimeout}ms\n\nstderr:\n${result.stderr}`, }, ], isError: true, }); } if (result.exitCode !== 0) { const { isError, output } = classifyNonZeroExit({ language, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, }); if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) { trackIndexed(Buffer.byteLength(output)); return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `${echo}${intentSearch(output, intent, isError ? `execute:${language}:error` : `execute:${language}`)}` }, ], isError, }); } // Auto-index large error output into FTS5 — no data loss if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) { trackIndexed(Buffer.byteLength(output)); return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `execute:${language}:error` : `execute:${language}`)}` }, ], isError, }); } return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `${echo}${output}` }, ], isError, }); } const stdout = result.stdout || "(no output)"; // Intent-driven search: if intent provided and output is large enough if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) { trackIndexed(Buffer.byteLength(stdout)); return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `${echo}${intentSearch(stdout, intent, `execute:${language}`)}` }, ], }); } // Auto-index large stdout into FTS5 — return pointer, not raw content if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) { const indexed = indexStdout(stdout, `execute:${language}`); // Prepend echo to the first text content so provenance still surfaces const echoed = { ...indexed, content: indexed.content.map((c, i) => i === 0 && c.type === "text" ? { ...c, text: `${echo}${(c as { text: string }).text}` } : c, ), }; return trackResponse("ctx_execute", echoed); } return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `${echo}${stdout}` }, ], }); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); return trackResponse("ctx_execute", { content: [ { type: "text" as const, text: `Runtime error: ${message}` }, ], isError: true, }); } }, ); // ───────────────────────────────────────────────────────── // Helper: index stdout into FTS5 knowledge base // ───────────────────────────────────────────────────────── function indexStdout( stdout: string, source: string, ): { content: Array<{ type: "text"; text: string }> } { const store = getStore(); trackIndexed(Buffer.byteLength(stdout)); const indexed = store.index({ content: stdout, source, attribution: currentAttribution() }); return { content: [ { type: "text" as const, text: `Indexed ${indexed.totalChunks} sections (${indexed.codeChunks} with code) from: ${indexed.label}\nUse ctx_search(queries: ["..."]) to query this content. Use source: "${indexed.label}" to scope results.`, }, ], }; } // ───────────────────────────────────────────────────────── // Helper: intent-driven search on execution output // ───────────────────────────────────────────────────────── const INTENT_SEARCH_THRESHOLD = 5_000; // bytes — ~80-100 lines const LARGE_OUTPUT_THRESHOLD = 102_400; // 100KB — auto-index into FTS5, return pointer function intentSearch( stdout: string, intent: string, source: string, maxResults: number = 5, ): string { const totalLines = stdout.split("\n").length; const totalBytes = Buffer.byteLength(stdout); // Index into the PERSISTENT store so user can ctx_search() later const persistent = getStore(); const indexed = persistent.indexPlainText(stdout, source, undefined, currentAttribution()); // Search the persistent store directly (porter → trigram → fuzzy) let results = persistent.searchWithFallback(intent, maxResults, source); // Extract distinctive terms as vocabulary hints for the LLM const distinctiveTerms = persistent.getDistinctiveTerms(indexed.sourceId); if (results.length === 0) { const lines = [ `Indexed ${indexed.totalChunks} sections from "${source}" into knowledge base.`, `No sections matched intent "${intent}" in ${totalLines}-line output (${(totalBytes / 1024).toFixed(1)}KB).`, ]; if (distinctiveTerms.length > 0) { lines.push(""); lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`); } lines.push(""); lines.push("Use ctx_search(queries: [...]) to explore the indexed content."); return lines.join("\n"); } // Return ONLY titles + first-line previews — not full content const lines = [ `Indexed ${indexed.totalChunks} sections from "${source}" into knowledge base.`, `${results.length} sections matched "${intent}" (${totalLines} lines, ${(totalBytes / 1024).toFixed(1)}KB):`, "", ]; for (const r of results) { const preview = r.content.split("\n")[0].slice(0, 120); lines.push(` - ${r.title}: ${preview}`); } if (distinctiveTerms.length > 0) { lines.push(""); lines.push(`Searchable terms: ${distinctiveTerms.join(", ")}`); } lines.push(""); lines.push("Use ctx_search(queries: [...]) to retrieve full content of any section."); return lines.join("\n"); } // ───────────────────────────────────────────────────────── // Tool: execute_file // ───────────────────────────────────────────────────────── server.registerTool( "ctx_execute_file", { // #852: the host's MCP approval prompt renders only the tool name/title + // raw args — the title is the one server-controlled signal, so make it // unambiguously announce code execution + file read for the reviewer. title: "Run code over a file (executes code, reads the given path)", // #846: runs arbitrary code over a file in a sandbox with full network access. annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, description: `Read a file into a sandboxed FILE_CONTENT variable and run code over it. Only what you console.log() enters your conversation — the file bytes stay in the sandbox. Think-in-Code applied to file-level analysis: Reading the whole file means every byte enters your conversation memory and costs reasoning capacity for the rest of the session. Running code over it here lets you keep the raw bytes out and only the derived answer in. Same principle as ctx_execute, scoped to one named file via the FILE_CONTENT variable. WHEN: - You want to KNOW SOMETHING ABOUT a file (line count, matches of a pattern, parsed structure, statistical aggregate) without needing to SEE all of it - The file is structured (CSV, JSON, log, code) and a code-level derivation is cheaper than reading verbatim - The file is large enough that reading the full content would burn meaningful conversation memory you need for the actual work - The derivation may itself produce a large output you want recall-by-topic on later — pass an \`intent\` string; outputs over ~5KB are auto-indexed and only matching sections come back, retrievable via ctx_search WHEN NOT: - You intend to EDIT the file — use Read so the subsequent Edit can match the exact text - You only need one specific line and you know its offset — Read with offset/limit is the simplest path - The file is small AND you will consume all of it for understanding/editing — Read directly RETURNS: Only what your code prints. The FILE_CONTENT variable holds the raw bytes inside the sandbox; nothing else leaves. When \`intent\` is set and output exceeds the auto-index threshold, the response carries searchable section titles + previews instead of the raw stdout. EXAMPLE: ctx_execute_file(path: "huge.log", language: "javascript", code: "const errs = FILE_CONTENT.split('\\\\n').filter(l => /ERROR|FATAL/.test(l)); console.log(\`\${errs.length} error lines\`); console.log(errs.slice(-5).join('\\\\n'))") EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const rows = FILE_CONTENT.split('\\\\n'); console.log(\`rows: \${rows.length - 1}, header: \${rows[0]}\`)")`, inputSchema: z.object({ path: z .string() .describe("Absolute file path or relative to project root"), language: z .enum([ "javascript", "typescript", "python", "shell", "ruby", "go", "rust", "php", "perl", "r", "elixir", "csharp", ]) .describe("Runtime language"), code: z .string() .describe( "Code to process FILE_CONTENT (file_content in Elixir). Print summary via console.log/print/echo/IO.puts/Console.WriteLine.", ), timeout: z .coerce.number() .optional() .describe("Max execution time in ms. When omitted, no server-side timer fires — the MCP host's RPC timeout governs."), intent: z .string() .optional() .describe( "What you're looking for in the output. When provided and output is large (>5KB), " + "returns only matching sections via BM25 search instead of truncated output.", ), }), }, async ({ path, language, code, timeout, intent }) => { // Security (#852): confine the processed file to the project root so // ctx_execute_file cannot be used to escape the host's sandbox/permission // controls. Runs before the deny-glob check — boundary first, then policy. const boundaryDenied = checkProjectBoundary(path, "ctx_execute_file"); if (boundaryDenied) return boundaryDenied; // Security: check file path against Read deny patterns const pathDenied = checkFilePathDenyPolicy(path, "ctx_execute_file"); if (pathDenied) return pathDenied; // Security: check code parameter against Bash deny patterns if (language === "shell") { const codeDenied = checkDenyPolicy(code, "execute_file"); if (codeDenied) return codeDenied; } else { const codeDenied = checkNonShellDenyPolicy(code, language, "execute_file"); if (codeDenied) return codeDenied; } try { const effTimeout = resolveExecTimeout(timeout); const result = await executor.executeFile({ path, language, code, timeout: effTimeout, }); // Echo path + executed source code before stdout for audit/debug // (Issues #717 + #736). const echo = buildExecuteEcho(language, code, path); if (result.timedOut) { return trackResponse("ctx_execute_file", { content: [ { type: "text" as const, text: `${echo}Timed out processing ${path} after ${effTimeout}ms`, }, ], isError: true, }); } if (result.exitCode !== 0) { const { isError, output } = classifyNonZeroExit({ language, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, }); if (intent && intent.trim().length > 0 && Buffer.byteLength(output) > INTENT_SEARCH_THRESHOLD) { trackIndexed(Buffer.byteLength(output)); return trackResponse("ctx_execute_file", { content: [ { type: "text" as const, text: `${echo}${intentSearch(output, intent, isError ? `file:${path}:error` : `file:${path}`)}` }, ], isError, }); } // Auto-index large error output into FTS5 — no data loss if (Buffer.byteLength(output) > LARGE_OUTPUT_THRESHOLD) { trackIndexed(Buffer.byteLength(output)); return trackResponse("ctx_execute_file", { content: [ { type: "text" as const, text: `${echo}${intentSearch(output, "errors failures exceptions", isError ? `file:${path}:error` : `file:${path}`)}` }, ], isError, }); } return trackResponse("ctx_execute_file", { content: [ { type: "text" as const, text: `${echo}${output}` }, ], isError, }); } const stdout = result.stdout || "(no output)"; if (intent && intent.trim().length > 0 && Buffer.byteLength(stdout) > INTENT_SEARCH_THRESHOLD) { trackIndexed(Buffer.byteLength(stdout)); return trackResponse("ctx_execute_file", { content: [ { type: "text" as const, text: `${echo}${intentSearch(stdout, intent, `file:${path}`)}` }, ], }); } // Auto-index large stdout into FTS5 — return pointer, not raw content if (Buffer.byteLength(stdout) > LARGE_OUTPUT_THRESHOLD) { const indexed = indexStdout(stdout, `file:${path}`); const echoed = { ...indexed, content: indexed.content.map((c, i) => i === 0 && c.type === "text" ? { ...c, text: `${echo}${(c as { text: string }).text}` } : c, ), }; return trackResponse("ctx_execute_file", echoed); } return trackResponse("ctx_execute_file", { content: [ { type: "text" as const, text: `${echo}${stdout}` }, ], }); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); return trackResponse("ctx_execute_file", { content: [ { type: "text" as const, text: `Runtime error: ${message}` }, ], isError: true, }); } }, ); // ───────────────────────────────────────────────────────── // Tool: index // ───────────────────────────────────────────────────────── server.registerTool( "ctx_index", { title: "Index Content", // #846: writes content into the local FTS5 store (additive, not destructive; // re-indexing the same content adds rows, so not idempotent). No network. annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, description: `Store content in a searchable knowledge base (BM25 over FTS5). Splits markdown by headings, keeps code blocks intact, and persists the raw chunks. The full content stays in storage — retrieve any section on-demand via ctx_search; nothing is summarized or truncated. WHEN: - Documentation from Context7, Skills, or MCP tools (API docs, framework guides, code examples) - API references (endpoint details, parameter specs, response schemas) - MCP tools/list output (exact tool signatures and descriptions) - Skill prompts and instructions that are too large to keep verbatim in conversation - README files, migration guides, changelog entries - Any content with code examples you may need to reference precisely later WHEN NOT: - Log files, test output, CSV, or build output — use ctx_execute_file, which processes in-sandbox without persisting bytes - Single-use ephemeral content you will not query later — keep it inline if it fits, or ctx_execute_file it RETURNS: Indexing metadata: chunk counts (total, code-bearing), source label, and the exact ctx_search call shape to query the indexed content. Raw content is NOT echoed back — it lives in storage, retrievable via ctx_search(source: "