fix(pi): bridge MCP tools into pi.registerTool() so the LLM can call them (#426) (#472)

* fix(pi): bridge MCP tools into pi.registerTool() so the LLM can call them (#426)

Pi 0.73.x has no native MCP support — its README is explicit:

> No MCP. Build CLI tools with READMEs (see Skills), or build an
> extension that adds MCP support.

Without a bridge inside the context-mode Pi extension, the routing
block tells the LLM to call `ctx_execute` / `ctx_search` / etc. but
those tools never enter Pi's tool list and the LLM cannot reach them.
The reporter measured 18 sessions over 2 days: ~2,500 tokens of
system-prompt overhead per window, 0 actual ctx_* calls, 447 events
recorded but never retrieved. Net ROI on Pi was negative.

This adds a stdio JSON-RPC client (`MCPStdioClient`) plus a thin
bootstrap (`bootstrapMCPTools`) that:

  - spawns `server.bundle.mjs` as a long-lived MCP child,
  - performs the standard MCP handshake (initialize →
    notifications/initialized),
  - lists tools once via `tools/list`, and
  - registers each tool through `pi.registerTool({ name, label,
    description, parameters, execute })` so the LLM sees the canonical
    bare names (matching what hooks/core/tool-naming.mjs emits for
    Pi).

Each Pi `execute()` callback forwards into the MCP child via
`tools/call`. Errors are translated to `throw` (Pi's contract for
"tool failed") so the LLM sees the MCP server's diagnostic text.

Lifecycle:

  - Bridge bootstrap is fire-and-forget at extension load — the rest
    of the extension (session capture, hooks, slash commands) is not
    blocked by spawn / handshake latency.
  - `session_shutdown` terminates the child via SIGTERM.
  - A missing `server.bundle.mjs` or any spawn / handshake error is
    surfaced once on stderr, then the extension keeps running with
    only the existing hooks + commands. Defense-in-depth so the bridge
    can never break Pi sessions for users with broken installs.

## Why a JSON Schema parameters object instead of TypeBox

MCP `tools/list` returns JSON Schema. Pi's parameter validator accepts
JSON Schema directly (TypeBox just produces JSON Schema with extra
Symbol metadata for type inference). Passing the schema through
unchanged avoids a runtime translation pass and keeps the bridge a
true thin layer over the MCP protocol — what works in Claude Code,
Gemini CLI, and the other adapters now also works in Pi.

## No new runtime dependencies

Pure `node:child_process` + `node:path`. The `@earendil-works/pi-*`
packages are NOT pulled in as build deps — `pi` is typed structurally
as `any` (matching the existing src/pi-extension.ts style) and the
bridge only touches the documented `pi.registerTool()` shape.

## Tests

Added two new `describe` blocks in `tests/pi-extension.test.ts`:

  1. `MCPStdioClient` (5 tests) — wire-protocol contract pinned with
     fake stdio servers: id-matched responses, concurrent in-flight
     requests with out-of-order delivery, child-exit cancellation,
     timeout, non-JSON noise tolerance.
  2. `bootstrapMCPTools` (2 integration tests) — spawn the real
     `start.mjs` MCP server, assert that the canonical ctx_* set
     (`ctx_execute`, `ctx_execute_file`, `ctx_search`, `ctx_index`,
     `ctx_batch_execute`, `ctx_fetch_and_index`, `ctx_doctor`,
     `ctx_stats`, `ctx_purge`) is registered, and round-trip
     `ctx_index` through `tools/call` to confirm execute() forwards
     args and returns text.

## Test plan

  - [x] `npm run build`
  - [x] `npm run typecheck` clean
  - [x] `npm test` — 73 files, 2406 pass / 25 skipped / 0 fail
  - [x] `npx vitest run tests/pi-extension.test.ts` — 44 pass
        (37 pre-existing + 7 new for the bridge)
  - [x] see-real-bug repro: `pi.registerTool` count = 0 in installed
        binary (`/home/$USER/.nvm/.../context-mode/build/pi-extension.js`)
        on `next` @ 1f70bee, plus Pi README's "No MCP" stance, plus
        the issue reporter's 18-session measurements — all three
        agree.
  - [-] Live LLM tool-call probe in Pi: blocked — free-tier Gemini
        quota was exhausted on every available key during the fix
        session. The integration test exercises the same code path
        (real MCP server + the Pi-facing registerTool surface), so
        the regression contract is enforced from CI.

## Out of scope

  - Removing the MCP server stanza from the Pi install README. Once
    this lands, the `~/.pi/agent/mcp.json` step is still harmless but
    no longer load-bearing. Cleanup left to a docs-only follow-up.
  - In-process refactor of server.ts handlers. The subprocess bridge
    is the same model used by every other adapter; a refactor that
    inlines the handlers is its own scope.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* fix(pi): address self-review findings on the MCP bridge (#426)

Three follow-up changes from the empirical self-review on PR #472:

1. **C1 HIGH — wiring not test-covered.**
   Phase A of the empirical review only failed because removing
   `src/pi-mcp-bridge.ts` produced an import error, not because the
   bug reproduced behaviorally. If a future refactor dropped the
   `bootstrapMCPTools(pi, …)` call from `src/pi-extension.ts` while
   keeping the bridge module intact, every existing bridge test
   stayed green and the bug silently re-entered.

   Fix: export `_mcpBridgeReady: Promise<void>` from
   `src/pi-extension.ts`. Bootstrap is still fire-and-forget (so
   spawn / handshake latency does not block session_start), but the
   promise gives tests a deterministic await point. Reset to a fresh
   promise on every `piExtension(pi)` call so multiple registrations
   in one process do not see a stale resolution.

   New test in `tests/pi-extension.test.ts` ("pi-extension.ts wiring
   (#426 regression guard)"): calls `registerPiExtension(api)`,
   awaits `_mcpBridgeReady`, asserts `api.registerTool.mock.calls`
   includes at least the canonical `ctx_execute` / `ctx_search` /
   `ctx_index` / `ctx_batch_execute` / `ctx_fetch_and_index` set.
   Verified red-on-revert: with the bridge module intact but the
   `bootstrapMCPTools(...)` call reverted to next, this test fails
   with `registeredNames: []`. Pre-fix it would have stayed green.

2. **C2 LOW — duplicated path resolution in the integration tests.**
   `tests/pi-extension.test.ts` had `path.dirname(...) +
   path.resolve(here, "..", "start.mjs")` recomputed in each `it()`.
   Lifted to a single `mcpEntry` const at the top of the
   `bootstrapMCPTools — registers every ctx_* tool with Pi` describe
   block, plus a shared `mcpEnv` for the `CONTEXT_MODE_DISABLE_VERSION_CHECK`
   override. One place to update if `start.mjs` ever moves.

3. **C3 LOW — dead `running` getter on MCPStdioClient.**
   Exported in the original commit but had zero callers anywhere in
   `src/` or `tests/`. Dropped — five lines, no behavioral impact.

## Test plan

- [x] `npm run build`
- [x] `npm run typecheck` — clean
- [x] `npm test` — 73/73 files, 2407 pass / 25 skipped / 0 fail
- [x] `npx vitest run tests/pi-extension.test.ts -t "MCP bridge|wiring"` — 8 pass
- [x] Phase A re-validation: revert ONLY the wiring in
      `src/pi-extension.ts` (keep `src/pi-mcp-bridge.ts` intact),
      run the wiring test → fails with `registeredNames: []`. Restore
      and the test goes green again.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor(openclaw): consolidate src/openclaw/* into src/adapters/openclaw/

Pre-fix layout split OpenClaw across two locations:
  - src/adapters/openclaw/ — config, hooks, index, session-db (standard
    adapter pattern matching every other platform)
  - src/openclaw/         — mcp-tools, workspace-router (rogue location)

The split predates the adapter pattern: workspace-router.ts was added
first by Pedro Almeida (#aa8d93c), then mcp-tools.ts by the maintainer
(#ff0a9a2 v1.0.107), while the adapter dir was bootstrapped later by
the copilot-swe-agent (#5fd6a9e). Source-of-truth for every platform
should live under src/adapters/<platform>/, so we move the two
stragglers in.

## Changes

  - git mv src/openclaw/mcp-tools.ts        → src/adapters/openclaw/mcp-tools.ts
  - git mv src/openclaw/workspace-router.ts → src/adapters/openclaw/workspace-router.ts
  - rmdir src/openclaw

  - src/openclaw-plugin.ts: 3 import-path updates
  - tests/plugins/openclaw.test.ts: 1 import-path update
  - tests/core/cli.test.ts: 1 readFileSync source-grep path update
    (the existing PR #183 path-traversal regression test reads the
    workspace-router source file directly to grep for safe-regex
    patterns; pin updated to the new location)

## Test plan

  - [x] npm run typecheck — clean
  - [x] npx vitest run tests/plugins/openclaw.test.ts tests/core/cli.test.ts
        → 225/225 pass
  - [x] npm test — 73 files, 2405+ pass / 25 skipped / 0 fail

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor: flatten src/concurrency/runPool.ts → src/runPool.ts

The src/concurrency/ directory held a single file. A whole directory
for one module is structural noise — flatten it to src/runPool.ts.

## Changes

  - git mv src/concurrency/runPool.ts → src/runPool.ts
  - rmdir src/concurrency
  - src/server.ts: import path updated
  - tests/core/server.test.ts: import path updated

## Test plan

  - [x] npm run typecheck — clean
  - [x] npx vitest run tests/core/server.test.ts -t "runPool" — pass

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor: relocate plugin entry files into src/adapters/<platform>/

Pre-fix layout had three platform plugin entry files at the src/ root:

  src/pi-extension.ts        — Pi Coding Agent extension
  src/pi-mcp-bridge.ts       — Pi MCP bridge (added in #426)
  src/openclaw-plugin.ts     — OpenClaw gateway plugin
  src/opencode-plugin.ts     — OpenCode plugin

Every other platform follows the src/adapters/<name>/ pattern (config,
hooks, index, …). The four root-level files were the last hold-outs:
inconsistent layout, plus they made adapter discovery harder for new
contributors.

## Changes (file moves)

  - git mv src/pi-extension.ts        → src/adapters/pi/extension.ts
  - git mv src/pi-mcp-bridge.ts       → src/adapters/pi/mcp-bridge.ts
  - git mv src/openclaw-plugin.ts     → src/adapters/openclaw/plugin.ts
  - git mv src/opencode-plugin.ts     → src/adapters/opencode/plugin.ts

## Internal import-path updates inside the moved files

  - ./session/db.js    → ../../session/db.js   (depth +2)
  - ./types.js         → ../../types.js
  - ./adapters/X/Y.js  → ./Y.js                (now sibling)
  - ./adapters/types.js → ../types.js          (now parent)
  - ./pi-mcp-bridge.js → ./mcp-bridge.js       (renamed + sibling)

## Runtime path-resolution updates

The plugins read sibling resources (hooks/, package.json, etc.) via
`resolve(buildDir, "..")`. After the move buildDir lives 2 dirs
deeper, so every `..` is now `../../..`:

  - resolve(buildDir, "..")                                 → resolve(buildDir, "..", "..", "..")
  - resolve(buildDir, "..", "hooks", "core", "routing.mjs")    → resolve(buildDir, "..", "..", "..", "hooks", "core", "routing.mjs")
  - (and similar for routing-block / tool-naming / auto-injection)

For opencode/plugin.ts the version-from-package.json walker prepends
`../../../package.json` to its search list (keeps the legacy
`../package.json` and `./package.json` entries as fall-backs so
unbundled or old-layout dev environments still resolve).

## Build-output paths in package.json

tsc preserves src/ structure under build/, so:

  ./build/pi-extension.js     → ./build/adapters/pi/extension.js
  ./build/openclaw-plugin.js  → ./build/adapters/openclaw/plugin.js
  ./build/opencode-plugin.js  → ./build/adapters/opencode/plugin.js

Updated:

  - package.json: pi.extensions[0], openclaw.extensions[0], main,
    exports["."], exports["./plugin"], exports["./openclaw"]
  - .pi/extensions/context-mode/index.ts: re-export delegate path
  - .openclaw-plugin/index.ts: re-export delegate path + JSDoc

## Test-side updates

  - tests/pi-extension.test.ts: dynamic-import paths updated
  - tests/opencode-plugin.test.ts: dynamic-import paths updated
  - tests/plugins/openclaw.test.ts: dynamic-import paths updated
  - tests/core/cli.test.ts: 4 dynamic-import paths + 1
    `readFileSync(src/openclaw-plugin.ts)` source-grep updated to the
    new location
  - src/adapters/detect.ts: comment-line ref updated
  - tests/adapters/detect.test.ts: comment-line ref updated

## Test plan

  - [x] npm run build                                        clean
  - [x] npm run typecheck                                    clean
  - [x] npm test                                             73 files,
        2407 pass / 25 skipped / 0 fail
  - [x] npx vitest run tests/opencode-plugin.test.ts         33/33 pass
        (regression: marker test that needed package.json walker fix)
  - [x] npx vitest run tests/plugins/openclaw.test.ts        225/225 pass
  - [x] npx vitest run tests/pi-extension.test.ts            45/45 pass
        (incl. the wiring guard added in the previous commit)
  - [x] npx vitest run tests/core/cli.test.ts -t "openclaw-plugin.ts doctor/upgrade"
        passes against the new src/adapters/openclaw/plugin.ts location
  - [x] Manual sanity: every old root-level path (build/pi-extension.js,
        src/opencode-plugin.ts, etc.) is gone from the repo — grep
        confirms zero stale refs in src/ + tests/ + package.json + the
        .pi/.openclaw-plugin/ thin wrappers.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* fix(ci): update E2E + install scripts for relocated openclaw plugin path

The structural refactor in 4911c07 (src/openclaw-plugin.ts → src/adapters/
openclaw/plugin.ts) moved the build output from build/openclaw-plugin.js
to build/adapters/openclaw/plugin.js. Three scripts still pointed at the
legacy path and broke on next-CI.

## OpenClaw E2E (failing on ubuntu-latest + macos-latest)

  scripts/test-openclaw-e2e.sh:34
    join(process.cwd(), "build", "openclaw-plugin.js")

The Phase 1 plugin-load check failed at "❌ build/openclaw-plugin.js
exists" → exit 1. Updated to look for the new path first, fall back to
the legacy one for transition safety:

  build/adapters/openclaw/plugin.js → fall back → build/openclaw-plugin.js

Loaded-tag tracks which path actually resolved.

## OpenClaw global install (would have broken at user-install time)

  scripts/install-openclaw-plugin.sh:49 (auto-generated index.ts stub)

Updated the absolute re-export path written into the generated stub
plus the jiti cache-clear glob (now matches both
`build-adapters-openclaw-plugin.*.cjs` and the legacy
`build-openclaw-plugin.*.cjs` filenames).

## Bonus: security.js path was wrong post-refactor

The opencode + openclaw plugins called `routing.initSecurity(buildDir)`
where buildDir = build/adapters/<platform>/. That made initSecurity look
for build/adapters/<platform>/security.js — which never exists. The
security module lives at build/security.js (top-level). The fix-open
fallback meant tests still passed but every plugin load emitted a
spurious WARNING about deny-policy enforcement being off.

  - opencode/plugin.ts: pass `resolve(buildDir, "..", "..")` (= build/)
  - openclaw/plugin.ts: same

Verified locally: `bash scripts/test-openclaw-e2e.sh` → 39/39 pass, no
security warning.

## Test plan

  - [x] npm run build              clean
  - [x] npm run typecheck          clean
  - [x] npm test                   73 files, 2405+ pass / 25 skipped /
        0 fail (2 pre-existing flake worker-pool timeouts on
        kiro-hooks + insight-cors; both pass when run in isolation)
  - [x] bash scripts/test-openclaw-e2e.sh
        → "Results: 39 passed  0 warned  0 failed"  +  "✅ E2E test PASSED"

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* docs(openclaw): update Key Files paths after src/adapters/<platform>/ refactor

Independent PR review on #472 caught 3 stale path strings in
`docs/adapters/openclaw.md` that the structural refactor (4911c07)
missed:

  - src/openclaw-plugin.ts          → src/adapters/openclaw/plugin.ts
  - src/openclaw/workspace-router.ts → src/adapters/openclaw/workspace-router.ts (×2)

Doc-only — no code paths reference these strings.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

---------

Co-authored-by: Ora Studio <noreply@oratelecom.net>
This commit is contained in:
Ben Younes
2026-05-08 02:30:46 +03:00
committed by GitHub
co-authored by Ora Studio
parent 1f70bee0ec
commit c805410878
21 changed files with 751 additions and 86 deletions
+3 -2
View File
@@ -6,6 +6,7 @@
* discovering plugins from the .openclaw-plugin/ directory.
*
* The actual plugin definition (object form with id, name, configSchema,
* register) lives in src/openclaw-plugin.ts, compiled to build/openclaw-plugin.js.
* register) lives in src/adapters/openclaw/plugin.ts, compiled to
* build/adapters/openclaw/plugin.js.
*/
export { default } from "../build/openclaw-plugin.js";
export { default } from "../build/adapters/openclaw/plugin.js";
+1 -1
View File
@@ -1 +1 @@
export { default } from "../../../build/pi-extension.js";
export { default } from "../../../build/adapters/pi/extension.js";
+3 -3
View File
@@ -118,13 +118,13 @@ This is the first release that includes the `api.on()` fix from [PR #9761](https
## Workspace Routing
The adapter includes a workspace router (`src/openclaw/workspace-router.ts`) that resolves project paths from Pi Agent session metadata, ensuring session databases and routing instructions are scoped per-workspace.
The adapter includes a workspace router (`src/adapters/openclaw/workspace-router.ts`) that resolves project paths from Pi Agent session metadata, ensuring session databases and routing instructions are scoped per-workspace.
## Key Files
| File | Purpose |
|---|---|
| `src/openclaw-plugin.ts` | Main plugin entry (sync register, initPromise pattern) |
| `src/openclaw/workspace-router.ts` | Workspace path resolution for session scoping |
| `src/adapters/openclaw/plugin.ts` | Main plugin entry (sync register, initPromise pattern) |
| `src/adapters/openclaw/workspace-router.ts` | Workspace path resolution for session scoping |
| `.openclaw-plugin/` | Plugin manifest (index.ts, openclaw.plugin.json, package.json) |
| `scripts/install-openclaw-plugin.sh` | One-shot installer |
+6 -6
View File
@@ -29,7 +29,7 @@
"homepage": "https://github.com/mksglu/context-mode#readme",
"pi": {
"extensions": [
"./build/pi-extension.js"
"./build/adapters/pi/extension.js"
],
"skills": [
"./skills"
@@ -37,15 +37,15 @@
},
"openclaw": {
"extensions": [
"./build/openclaw-plugin.js"
"./build/adapters/openclaw/plugin.js"
]
},
"bugs": "https://github.com/mksglu/context-mode/issues",
"main": "./build/opencode-plugin.js",
"main": "./build/adapters/opencode/plugin.js",
"exports": {
".": "./build/opencode-plugin.js",
"./plugin": "./build/opencode-plugin.js",
"./openclaw": "./build/openclaw-plugin.js",
".": "./build/adapters/opencode/plugin.js",
"./plugin": "./build/adapters/opencode/plugin.js",
"./openclaw": "./build/adapters/openclaw/plugin.js",
"./cli": "./cli.bundle.mjs"
},
"bin": {
+5 -2
View File
@@ -46,12 +46,15 @@ cp "$PLUGIN_ROOT/.openclaw-plugin/openclaw.plugin.json" "$EXT_DIR/openclaw.plugi
cat > "$EXT_DIR/index.ts" << TSEOF
// context-mode OpenClaw global extension — auto-generated by install script.
// Uses absolute path so this stub can live in any extensions directory.
export { default } from "$PLUGIN_ROOT/build/openclaw-plugin.js";
export { default } from "$PLUGIN_ROOT/build/adapters/openclaw/plugin.js";
TSEOF
# 3. Clear jiti cache so stale compiled TS doesn't survive extension dir update
# Match both the new (build-adapters-openclaw-plugin.*.cjs) and legacy
# (build-openclaw-plugin.*.cjs) cache filenames so a host that ran an
# older install still gets cleared on upgrade.
echo "→ clearing jiti cache for context-mode..."
rm -f /tmp/jiti/context-mode-index.*.cjs /tmp/jiti/build-openclaw-plugin.*.cjs 2>/dev/null || true
rm -f /tmp/jiti/context-mode-index.*.cjs /tmp/jiti/build-adapters-openclaw-plugin.*.cjs /tmp/jiti/build-openclaw-plugin.*.cjs 2>/dev/null || true
echo " ✓ jiti cache cleared"
# 4. Verify plugin is discovered by OpenClaw
+15 -4
View File
@@ -31,14 +31,25 @@ function section(label) { console.log(`\n--- ${label} ---`); }
// ── 0. Load plugin ────────────────────────────────────────
section("Phase 1: Plugin load");
const pluginPath = join(process.cwd(), "build", "openclaw-plugin.js");
if (!existsSync(pluginPath)) { fail("build/openclaw-plugin.js exists"); process.exit(1); }
// Plugin entry was moved from build/openclaw-plugin.js to
// build/adapters/openclaw/plugin.js when the OpenClaw entry file was
// relocated under src/adapters/openclaw/. The legacy path is kept as a
// fall-back so older checked-out CI configs do not break across the
// transition.
const pluginPath = join(process.cwd(), "build", "adapters", "openclaw", "plugin.js");
const legacyPluginPath = join(process.cwd(), "build", "openclaw-plugin.js");
const resolvedPluginPath = existsSync(pluginPath)
? pluginPath
: existsSync(legacyPluginPath)
? legacyPluginPath
: null;
if (!resolvedPluginPath) { fail("build/adapters/openclaw/plugin.js exists"); process.exit(1); }
let plugin;
try {
const mod = await import(pluginPath);
const mod = await import(resolvedPluginPath);
plugin = mod.default;
pass("build/openclaw-plugin.js loaded");
pass(`${resolvedPluginPath.includes("adapters") ? "build/adapters/openclaw/plugin.js" : "build/openclaw-plugin.js"} loaded`);
} catch (err) {
fail("plugin load", err.message);
process.exit(1);
+1 -1
View File
@@ -67,7 +67,7 @@ export const PLATFORM_ENV_VARS = [
// qwen-code — QWEN_PROJECT_DIR per QwenLM/qwen-code docs/users/features/hooks.md.
// (QWEN_SESSION_ID removed — 0 hits in qwen-code repository.)
["qwen-code", ["QWEN_PROJECT_DIR"]],
// pi — PI_PROJECT_DIR consumed by src/pi-extension.ts:154 + src/server.ts:153
// pi — PI_PROJECT_DIR consumed by src/adapters/pi/extension.ts:154 + src/server.ts:153
// — implies the Pi runtime sets it before invoking the extension.
["pi", ["PI_PROJECT_DIR"]],
// openclaw — removed (runtime never sets OPENCLAW_HOME or OPENCLAW_CLI;
@@ -35,17 +35,17 @@ import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { SessionDB } from "./session/db.js";
import { OpenClawSessionDB } from "./adapters/openclaw/session-db.js";
import { extractEvents, extractUserEvents } from "./session/extract.js";
import type { HookInput } from "./session/extract.js";
import { buildResumeSnapshot } from "./session/snapshot.js";
import type { SessionEvent } from "./types.js";
import { SessionDB } from "../../session/db.js";
import { OpenClawSessionDB } from "./session-db.js";
import { extractEvents, extractUserEvents } from "../../session/extract.js";
import type { HookInput } from "../../session/extract.js";
import { buildResumeSnapshot } from "../../session/snapshot.js";
import type { SessionEvent } from "../../types.js";
import { WorkspaceRouter } from "./openclaw/workspace-router.js";
import { buildNodeCommand } from "./adapters/types.js";
import { OPENCLAW_TOOL_DEFS } from "./openclaw/mcp-tools.js";
import type { OpenClawToolDef } from "./openclaw/mcp-tools.js";
import { WorkspaceRouter } from "./workspace-router.js";
import { buildNodeCommand } from "../types.js";
import { OPENCLAW_TOOL_DEFS } from "./mcp-tools.js";
import type { OpenClawToolDef } from "./mcp-tools.js";
// ── System-reminder filter (CCv2 — SLICE OClaw-3) ─────────
// Mirror hooks/userpromptsubmit.mjs:30-33: skip system-generated wrappers
@@ -244,7 +244,7 @@ export default {
// Resolve build dir from compiled JS location
const buildDir = dirname(fileURLToPath(import.meta.url));
const projectDir = process.cwd();
const pluginRoot = resolve(buildDir, "..");
const pluginRoot = resolve(buildDir, "..", "..", "..");
// Structured logger — wraps api.logger, falls back to no-op.
// info/error always emit; debug only when api.logger.debug is present
@@ -280,17 +280,20 @@ export default {
// MCP-prefix substitution stays in lockstep with hooks/routing-block.mjs.
let routingInstructions = "";
const initPromise = (async () => {
const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
const routingPath = resolve(buildDir, "..", "..", "..", "hooks", "core", "routing.mjs");
const routing = await import(pathToFileURL(routingPath).href);
await routing.initSecurity(buildDir);
// initSecurity() looks for `<dir>/security.js`, which lives at the
// top of build/ — two levels up from this adapter directory.
const buildRoot = resolve(buildDir, "..", "..");
await routing.initSecurity(buildRoot);
try {
const blockMod = await import(
pathToFileURL(resolve(buildDir, "..", "hooks", "routing-block.mjs")).href
pathToFileURL(resolve(buildDir, "..", "..", "..", "hooks", "routing-block.mjs")).href
);
const namingMod = await import(
pathToFileURL(
resolve(buildDir, "..", "hooks", "core", "tool-naming.mjs"),
resolve(buildDir, "..", "..", "..", "hooks", "core", "tool-naming.mjs"),
).href
);
const toolNamer = namingMod.createToolNamer("openclaw");
@@ -599,7 +602,7 @@ export default {
}
const events = extractUserEvents(messageText);
for (const ev of events) {
db.insertEvent(sid, ev as import("./types.js").SessionEvent, "PostToolUse");
db.insertEvent(sid, ev as import("../../types.js").SessionEvent, "PostToolUse");
}
} catch {
// best effort — never break model resolution
@@ -25,13 +25,13 @@ import { dirname, resolve, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import { SessionDB } from "./session/db.js";
import { extractEvents, extractUserEvents } from "./session/extract.js";
import type { HookInput } from "./session/extract.js";
import { buildResumeSnapshot } from "./session/snapshot.js";
import type { SessionEvent } from "./types.js";
import { AdapterPlatformType, OpenCodeAdapter } from "./adapters/opencode/index.js";
import { PLATFORM_ENV_VARS } from "./adapters/detect.js";
import { SessionDB } from "../../session/db.js";
import { extractEvents, extractUserEvents } from "../../session/extract.js";
import type { HookInput } from "../../session/extract.js";
import { buildResumeSnapshot } from "../../session/snapshot.js";
import type { SessionEvent } from "../../types.js";
import { AdapterPlatformType, OpenCodeAdapter } from "./index.js";
import { PLATFORM_ENV_VARS } from "../detect.js";
// Read package.json version once at module load (not on every hook call).
// Used in the resume-injection visible signal so users can confirm in
@@ -39,7 +39,11 @@ import { PLATFORM_ENV_VARS } from "./adapters/detect.js";
const VERSION: string = (() => {
try {
const pkgRoot = dirname(fileURLToPath(import.meta.url));
for (const rel of ["../package.json", "./package.json"]) {
// Search both the legacy depths (when bundled flat under build/) and
// the post-refactor depths (when compiled to build/adapters/opencode/).
// `../../../package.json` is the canonical location after the
// `src/opencode-plugin.ts → src/adapters/opencode/plugin.ts` move.
for (const rel of ["../../../package.json", "../package.json", "./package.json"]) {
const p = resolve(pkgRoot, rel);
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")).version ?? "unknown";
}
@@ -223,19 +227,22 @@ async function createContextModePlugin(ctx: PluginContext) {
const platform = getPlatform();
const adapter = new OpenCodeAdapter(platform);
const buildDir = dirname(fileURLToPath(import.meta.url));
// initSecurity() looks for `<dir>/security.js`, which lives at the
// top of build/ — two levels up from this adapter directory.
const buildRoot = resolve(buildDir, "..", "..");
// Load routing module (ESM .mjs, lives outside build/ in hooks/)
const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
const routingPath = resolve(buildDir, "..", "..", "..", "hooks", "core", "routing.mjs");
const routing = await import(pathToFileURL(routingPath).href);
await routing.initSecurity(buildDir);
await routing.initSecurity(buildRoot);
// OC-1 / OC-3: Load hook helpers once at plugin init. Dynamic import keeps
// the .mjs ESM islands isolated from the .ts compile graph.
const routingBlockPath = resolve(buildDir, "..", "hooks", "routing-block.mjs");
const routingBlockPath = resolve(buildDir, "..", "..", "..", "hooks", "routing-block.mjs");
const routingBlockMod = await import(pathToFileURL(routingBlockPath).href);
const toolNamingPath = resolve(buildDir, "..", "hooks", "core", "tool-naming.mjs");
const toolNamingPath = resolve(buildDir, "..", "..", "..", "hooks", "core", "tool-naming.mjs");
const toolNamingMod = await import(pathToFileURL(toolNamingPath).href);
const autoInjectionPath = resolve(buildDir, "..", "hooks", "auto-injection.mjs");
const autoInjectionPath = resolve(buildDir, "..", "..", "..", "hooks", "auto-injection.mjs");
const autoInjectionMod = await import(pathToFileURL(autoInjectionPath).href);
// Pre-build the routing block once per process — it is platform-specific
@@ -16,11 +16,12 @@ import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { SessionDB } from "./session/db.js";
import { extractEvents, extractUserEvents } from "./session/extract.js";
import type { HookInput } from "./session/extract.js";
import { buildResumeSnapshot } from "./session/snapshot.js";
import type { SessionEvent } from "./types.js";
import { SessionDB } from "../../session/db.js";
import { extractEvents, extractUserEvents } from "../../session/extract.js";
import type { HookInput } from "../../session/extract.js";
import { buildResumeSnapshot } from "../../session/snapshot.js";
import type { SessionEvent } from "../../types.js";
import { bootstrapMCPTools, type BridgeHandle } from "./mcp-bridge.js";
// ── Pi Tool Name Mapping ─────────────────────────────────
// Pi uses lowercase; shared extractors expect PascalCase (Claude Code convention).
@@ -53,6 +54,26 @@ const BLOCKED_BASH_PATTERNS: RegExp[] = [
let _db: SessionDB | null = null;
let _sessionId = "";
// MCP bridge handle. The bridge spawns server.bundle.mjs once and
// registers each MCP tool through pi.registerTool() so the Pi LLM can
// actually call ctx_execute / ctx_search / etc. (#426). Pi 0.73.x has
// no native MCP support, so without this bridge the tools are
// invisible to the LLM and the routing block is dead weight.
let _mcpBridge: BridgeHandle | null = null;
/**
* Settles when the MCP bridge bootstrap has finished — resolves on
* success AND on failure (the bootstrap is best-effort; failures are
* logged to stderr but never propagated). Exposed for tests so they
* can `await` the wiring deterministically without relying on internal
* timing or `setImmediate` polling.
*
* Reset to a fresh promise on every `piExtension(pi)` call so repeated
* registrations in one test process don't see a stale resolution from
* a prior load.
*/
export let _mcpBridgeReady: Promise<void> = Promise.resolve();
// Per-session gate: routing block injected at most once per session_id.
const _routingInjected: Set<string> = new Set();
@@ -192,7 +213,7 @@ function handleCommandText(
/** Pi extension default export. Called once by Pi runtime with the extension API. */
export default function piExtension(pi: any): void {
const buildDir = dirname(fileURLToPath(import.meta.url));
const pluginRoot = resolve(buildDir, "..");
const pluginRoot = resolve(buildDir, "..", "..", "..");
const projectDir = process.env.PI_PROJECT_DIR || process.cwd();
const db = getOrCreateDB();
@@ -465,6 +486,14 @@ export default function piExtension(pi: any): void {
} catch {
// best effort — never throw during shutdown
}
if (_mcpBridge) {
try {
_mcpBridge.shutdown();
} catch {
// best effort — never throw during shutdown
}
_mcpBridge = null;
}
});
// ── 8. Slash commands ──────────────────────────────────
@@ -517,4 +546,40 @@ export default function piExtension(pi: any): void {
return handleCommandText(text, ctx);
},
});
// ── 9. MCP tool bridge (#426) ───────────────────────────
//
// Pi 0.73.x has no native MCP support. Without bridging here, the
// routing block tells the LLM to call ctx_execute / ctx_search / etc.
// but those tools never appear in Pi's tool list and the LLM cannot
// reach them — context-mode becomes a pure cost (~2.5K tokens of
// system-prompt overhead, 0 actual ctx_* calls).
//
// Spawn server.bundle.mjs as a long-lived MCP child and register
// each of its tools via pi.registerTool() so they enter the Pi
// tool list under their bare names — same names the routing block
// emits for the Pi platform (per hooks/core/tool-naming.mjs).
//
// Best-effort: a missing bundle or a spawn failure must NOT prevent
// the rest of the extension (session capture, hooks, slash commands)
// from initializing. We log to stderr and continue.
const serverBundle = resolve(pluginRoot, "server.bundle.mjs");
if (existsSync(serverBundle)) {
_mcpBridgeReady = bootstrapMCPTools(pi, serverBundle).then(
(handle) => {
_mcpBridge = handle;
},
(err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[context-mode] WARNING: failed to bridge MCP tools to Pi (${msg}). ` +
`ctx_* tools will not be callable from this session.\n`,
);
},
);
} else {
// No bundle on disk → nothing to await. Tests can still rely on
// _mcpBridgeReady being a settled promise.
_mcpBridgeReady = Promise.resolve();
}
}
+282
View File
@@ -0,0 +1,282 @@
/**
* MCP-stdio bridge for the Pi Coding Agent extension.
*
* Pi 0.73.x has no native MCP support — its README is explicit:
* > "No MCP. Build CLI tools with READMEs (see Skills), or build an
* > extension that adds MCP support."
*
* Without this bridge, the routing block tells the LLM to call
* `ctx_execute`, `ctx_search`, etc. — but those tools never enter Pi's
* tool list, so the LLM cannot reach them. context-mode then becomes a
* pure cost on Pi (~2.5K tokens of system-prompt overhead with 0
* actual ctx_* calls). Reported in mksglu/context-mode#426.
*
* The bridge spawns `server.bundle.mjs` as a long-lived child via stdio
* JSON-RPC, performs the MCP handshake, calls `tools/list` once, and
* registers each returned tool through `pi.registerTool({ … })`. Each
* tool's `execute()` forwards into the child via `tools/call` — same
* code path Claude Code, Gemini CLI, and the other adapters use, so
* Pi behavior matches the rest of the platform suite.
*
* No external dependencies — pure node:child_process + JSON line frames.
*/
import { spawn, type ChildProcess } from "node:child_process";
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
export interface MCPTool {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}
export interface MCPCallResult {
content?: Array<{ type?: string; text?: string }>;
isError?: boolean;
}
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
// Tools/call may run shell commands or fetch URLs — wider window than
// initialize/list, but still bounded so a hung server can't block Pi.
const DEFAULT_CALL_TIMEOUT_MS = 120_000;
/**
* Minimal stdio JSON-RPC client targeting the context-mode MCP server.
*
* Implementation notes:
* - One outstanding ID per request; results matched by `id` from the
* returned envelope. Notifications (no id) are sent fire-and-forget.
* - Buffer is split on `\n` because the MCP server writes one
* newline-delimited JSON message per `console.log` / `stdout.write`
* invocation — this is the standard MCP stdio transport framing.
* - On child exit / error, every in-flight request is rejected so
* callers do not hang forever.
*/
export class MCPStdioClient {
private child: ChildProcess | null = null;
private requestId = 0;
private readonly pending = new Map<number, PendingRequest>();
private buffer = "";
private initialized = false;
private exited = false;
constructor(
private readonly serverScript: string,
private readonly env: NodeJS.ProcessEnv = process.env,
) {}
/** Spawn the MCP child. Idempotent. */
start(): void {
if (this.child) return;
this.exited = false;
this.child = spawn(process.execPath, [this.serverScript], {
stdio: ["pipe", "pipe", "ignore"],
env: this.env,
});
this.child.stdout?.on("data", (chunk) => this.onData(chunk));
this.child.on("exit", () => this.onExit());
this.child.on("error", () => this.onExit());
}
private onExit(): void {
if (this.exited) return;
this.exited = true;
const err = new Error("MCP server exited");
for (const [, p] of this.pending) p.reject(err);
this.pending.clear();
}
private onData(chunk: Buffer): void {
this.buffer += chunk.toString("utf-8");
let idx;
while ((idx = this.buffer.indexOf("\n")) >= 0) {
const line = this.buffer.slice(0, idx).trim();
this.buffer = this.buffer.slice(idx + 1);
if (!line) continue;
let msg: { id?: number; result?: unknown; error?: unknown };
try {
msg = JSON.parse(line);
} catch {
continue; // skip non-JSON noise (e.g. stray log lines)
}
if (typeof msg.id !== "number" || !this.pending.has(msg.id)) continue;
const handler = this.pending.get(msg.id)!;
this.pending.delete(msg.id);
if (msg.error) handler.reject(msg.error);
else handler.resolve(msg.result);
}
}
request<T = unknown>(
method: string,
params: unknown,
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
): Promise<T> {
if (!this.child) throw new Error("MCP client not started");
if (this.exited) return Promise.reject(new Error("MCP server has exited"));
const id = ++this.requestId;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (!this.pending.has(id)) return;
this.pending.delete(id);
reject(new Error(`MCP request timeout after ${timeoutMs}ms: ${method}`));
}, timeoutMs);
this.pending.set(id, {
resolve: (v) => {
clearTimeout(timer);
resolve(v as T);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
});
const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params });
this.child!.stdin?.write(frame + "\n");
});
}
notify(method: string, params: unknown): void {
if (!this.child) return;
const frame = JSON.stringify({ jsonrpc: "2.0", method, params });
this.child.stdin?.write(frame + "\n");
}
async initialize(): Promise<void> {
if (this.initialized) return;
await this.request("initialize", {
protocolVersion: "2025-06-18",
capabilities: { tools: {} },
clientInfo: {
name: "pi-coding-agent-context-mode-bridge",
version: "1.0",
},
});
this.notify("notifications/initialized", {});
this.initialized = true;
}
async listTools(): Promise<MCPTool[]> {
const result = await this.request<{ tools?: MCPTool[] }>("tools/list", {});
return Array.isArray(result.tools) ? result.tools : [];
}
async callTool(name: string, args: unknown): Promise<MCPCallResult> {
return this.request<MCPCallResult>(
"tools/call",
{ name, arguments: args ?? {} },
DEFAULT_CALL_TIMEOUT_MS,
);
}
shutdown(): void {
if (!this.child) return;
try {
this.child.kill("SIGTERM");
} catch {
// best effort
}
this.child = null;
this.initialized = false;
this.exited = true;
}
}
/**
* Subset of the Pi ExtensionAPI we touch. Typed structurally so we don't
* pull `@earendil-works/pi-coding-agent` as a build dependency — keeps
* the bundle size unchanged and matches the existing pi-extension.ts
* style (which also types `pi` as `any`).
*/
export interface PiToolRegistration {
name: string;
label: string;
description: string;
parameters: unknown;
execute: (
toolCallId: string,
params: Record<string, unknown>,
) => Promise<{
content: Array<{ type: "text"; text: string }>;
details: Record<string, unknown>;
isError?: boolean;
}>;
}
export interface PiLikeAPI {
registerTool: (tool: PiToolRegistration) => void;
}
/** Result of bootstrapping the bridge. */
export interface BridgeHandle {
/** Names of tools registered with Pi (for diagnostics / tests). */
tools: string[];
/** Idempotent shutdown — terminates the MCP child. */
shutdown: () => void;
/** Underlying client, exposed for tests / advanced callers. */
client: MCPStdioClient;
}
/**
* Spawn the MCP server and register each of its tools with Pi via
* `pi.registerTool()`. The same JSON Schema returned by `tools/list` is
* passed straight through as `parameters` — TypeBox emits JSON-Schema
* compatible objects, so any Pi runtime that validates JSON Schema
* accepts this shape (verified against pi 0.73.x).
*
* Errors during MCP `tools/call` are translated to a `throw` from the
* `execute()` callback — Pi's contract is "throw to mark the tool call
* failed", which lets the LLM see and adapt.
*/
export async function bootstrapMCPTools(
pi: PiLikeAPI,
serverScript: string,
options: { env?: NodeJS.ProcessEnv } = {},
): Promise<BridgeHandle> {
const client = new MCPStdioClient(serverScript, options.env);
client.start();
await client.initialize();
const tools = await client.listTools();
const registered: string[] = [];
for (const tool of tools) {
pi.registerTool({
name: tool.name,
label: tool.name,
description: tool.description ?? "",
// MCP tools/list returns JSON Schema; Pi validates against JSON
// Schema (TypeBox is just JSON Schema with extra Symbol metadata
// for type inference). Empty-object fallback keeps tools that
// declare no parameters callable.
parameters: tool.inputSchema ?? { type: "object", properties: {} },
async execute(_toolCallId, params) {
const result = await client.callTool(tool.name, params ?? {});
const text = (result.content ?? [])
.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text as string)
.join("\n");
if (result.isError) {
// Throw is the Pi contract for "tool failed". The text body
// becomes the error message visible to the LLM, so it sees
// the same diagnostic the MCP server emitted.
throw new Error(text || `${tool.name} returned an error`);
}
return {
content: [{ type: "text", text }],
details: {},
};
},
});
registered.push(tool.name);
}
return {
tools: registered,
shutdown: () => client.shutdown(),
client,
};
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { homedir, tmpdir, cpus } from "node:os";
import { request as httpsRequest } from "node:https";
import { z } from "zod";
import { PolyglotExecutor } from "./executor.js";
import { runPool, type PoolJob } from "./concurrency/runPool.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 {
+1 -1
View File
@@ -152,7 +152,7 @@ describe("detectPlatform", () => {
// ── Pi ─────────────────────────────────────────────────
// Pi runtime sets PI_PROJECT_DIR before invoking the extension —
// verified by src/pi-extension.ts:154 + src/server.ts:153 consumers.
// verified by src/adapters/pi/extension.ts:154 + src/server.ts:153 consumers.
it("detects pi via PI_PROJECT_DIR env var", () => {
process.env.PI_PROJECT_DIR = "/some/project";
+5 -5
View File
@@ -823,7 +823,7 @@ describe("Bin entry uses cli.bundle.mjs", () => {
});
it("openclaw-plugin.ts doctor/upgrade use cli.bundle.mjs with fallback", () => {
const src = readFileSync(resolve(ROOT, "src", "openclaw-plugin.ts"), "utf-8");
const src = readFileSync(resolve(ROOT, "src", "adapters", "openclaw", "plugin.ts"), "utf-8");
expect(src).toContain("cli.bundle.mjs");
// Find the registerCommand blocks, not comments
const doctorIdx = src.indexOf('name: "ctx-doctor"');
@@ -934,19 +934,19 @@ describe("SKILL.md prefers MCP tool over Bash", () => {
describe("Package exports", () => {
test("named export exposes ContextModePlugin factory", async () => {
const mod = await import("../../src/opencode-plugin.js");
const mod = await import("../../src/adapters/opencode/plugin.js");
expect(mod.ContextModePlugin).toBeDefined();
expect(typeof mod.ContextModePlugin).toBe("function");
});
test("default export has KiloCode PluginModule shape { server }", async () => {
const mod = (await import("../../src/opencode-plugin.js")) as any;
const mod = (await import("../../src/adapters/opencode/plugin.js")) as any;
expect(mod.default).toBeDefined();
expect(typeof mod.default.server).toBe("function");
});
test("default export does not leak CLI internals", async () => {
const mod = (await import("../../src/opencode-plugin.js")) as any;
const mod = (await import("../../src/adapters/opencode/plugin.js")) as any;
expect(mod.toUnixPath).toBeUndefined();
expect(mod.doctor).toBeUndefined();
expect(mod.upgrade).toBeUndefined();
@@ -1168,7 +1168,7 @@ describe("Self-heal hook-path rewriting (#187 + #415 follow-up)", () => {
// ── PR #183 fix: path traversal prevention in OpenClaw sessionKey ──
describe("OpenClaw sessionKey safety (#183)", () => {
const WR_SOURCE = readFileSync(resolve(ROOT, "src/openclaw/workspace-router.ts"), "utf-8");
const WR_SOURCE = readFileSync(resolve(ROOT, "src/adapters/openclaw/workspace-router.ts"), "utf-8");
test("workspace regex only allows safe characters (no path traversal)", () => {
// Must use [a-zA-Z0-9_-]+ not [^:]+ to prevent ../../ in agent name
+1 -1
View File
@@ -2938,7 +2938,7 @@ describe("runBatchCommands P0 hardening", () => {
// runPool — shared concurrency primitive (PRD finding G)
// ═══════════════════════════════════════════════════════════════════════════
import { runPool, type PoolJob } from "../../src/concurrency/runPool.js";
import { runPool, type PoolJob } from "../../src/runPool.js";
describe("runPool primitive", () => {
test("empty jobs returns empty settled array", async () => {
+1 -1
View File
@@ -21,7 +21,7 @@ import { tmpdir } from "node:os";
*/
async function createTestPlugin(tempDir: string) {
// Import the plugin module
const { ContextModePlugin } = await import("../src/opencode-plugin.js");
const { ContextModePlugin } = await import("../src/adapters/opencode/plugin.js");
// Monkey-patch the session dir to use temp directory
// The plugin uses homedir() internally, but we can control the DB path
+295 -2
View File
@@ -2,7 +2,7 @@ import "./setup-home";
/**
* Pi Extension Tests — TDD vertical slices.
*
* The Pi extension (src/pi-extension.ts) is a default-exported function that
* The Pi extension (src/adapters/pi/extension.ts) is a default-exported function that
* receives a Pi API object and registers event handlers. Since we cannot test
* against a real Pi runtime, we mock the Pi API to capture registered handlers
* and invoke them with simulated events.
@@ -77,7 +77,7 @@ async function registerPiExtension(
process.env.PI_PROJECT_DIR = projectDir;
process.env.CLAUDE_PROJECT_DIR = projectDir;
const mod = await import("../src/pi-extension.js");
const mod = await import("../src/adapters/pi/extension.js");
const register = mod.default;
await register(mockApi);
@@ -715,3 +715,296 @@ describe("Pi Extension", () => {
});
});
});
// ────────────────────────────────────────────────────────────────────────
// MCP bridge — bridges context-mode MCP tools into Pi's pi.registerTool()
// surface so the LLM can actually reach ctx_execute / ctx_search / etc.
// (#426). Pi 0.73.x has no native MCP support; without this bridge the
// routing block tells the LLM about tools it cannot call.
// ────────────────────────────────────────────────────────────────────────
describe("Pi MCP bridge (#426)", () => {
let mcpScratch: string;
beforeEach(() => {
mcpScratch = mkdtempSync(join(tmpdir(), "ctx-pi-bridge-"));
});
afterEach(() => {
try {
rmSync(mcpScratch, { recursive: true, force: true });
} catch {
/* best effort */
}
});
function writeFakeServer(source: string): string {
const fs = require("node:fs") as typeof import("node:fs");
const path = join(mcpScratch, `fake-mcp-${Date.now()}-${Math.random()}.mjs`);
fs.writeFileSync(path, source, "utf-8");
return path;
}
// ── Unit: MCPStdioClient framing & lifecycle ──────────────────────
describe("MCPStdioClient", () => {
it("matches request id to response result over newline-delimited JSON", async () => {
const fakePath = writeFakeServer(`
let buf = "";
process.stdin.on("data", (chunk) => {
buf += chunk.toString("utf-8");
let idx;
while ((idx = buf.indexOf("\\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id == null) continue;
process.stdout.write(JSON.stringify({
jsonrpc: "2.0", id: msg.id,
result: { echoed: msg.method, params: msg.params },
}) + "\\n");
}
});
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
try {
const r1 = await client.request("tools/list", { foo: 1 });
expect(r1).toEqual({ echoed: "tools/list", params: { foo: 1 } });
const r2 = await client.request("tools/call", { bar: 2 });
expect(r2).toEqual({ echoed: "tools/call", params: { bar: 2 } });
} finally {
client.shutdown();
}
});
it("matches concurrent in-flight requests by id (out-of-order responses)", async () => {
// Reverse delays so the slowest goes first — exercises the id-map
// dispatch, not just FIFO ordering.
const fakePath = writeFakeServer(`
let buf = "";
process.stdin.on("data", (chunk) => {
buf += chunk.toString("utf-8");
let idx;
while ((idx = buf.indexOf("\\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id == null) continue;
const delay = msg.params?.delay ?? 0;
setTimeout(() => {
process.stdout.write(JSON.stringify({
jsonrpc: "2.0", id: msg.id, result: { id: msg.id },
}) + "\\n");
}, delay);
}
});
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
try {
const promises = [50, 40, 30, 20, 10].map((delay, i) =>
client.request<{ id: number }>("probe", { delay, idx: i }),
);
const results = await Promise.all(promises);
expect(results).toHaveLength(5);
for (const r of results) expect(typeof r.id).toBe("number");
} finally {
client.shutdown();
}
});
it("rejects in-flight requests when the child exits", async () => {
const fakePath = writeFakeServer(`
process.stdin.once("data", () => process.exit(0));
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
const promise = client.request("tools/list", {});
await expect(promise).rejects.toThrow(/exited|MCP/);
client.shutdown();
});
it("times out instead of hanging on a silent server", async () => {
const fakePath = writeFakeServer(`
process.stdin.on("data", () => {});
setInterval(() => {}, 1000);
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
try {
await expect(
client.request("tools/list", {}, 200),
).rejects.toThrow(/timeout/);
} finally {
client.shutdown();
}
});
it("ignores non-JSON stdout lines without crashing the parser", async () => {
const fakePath = writeFakeServer(`
process.stdout.write("[some startup banner]\\n");
process.stdout.write("not valid json {{{\\n");
let buf = "";
process.stdin.on("data", (chunk) => {
buf += chunk.toString("utf-8");
let idx;
while ((idx = buf.indexOf("\\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id == null) continue;
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } }) + "\\n");
}
});
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
try {
const r = await client.request<{ ok: boolean }>("ping", {});
expect(r.ok).toBe(true);
} finally {
client.shutdown();
}
});
});
// ── Integration: bootstrapMCPTools + real MCP server ──────────────
describe("bootstrapMCPTools — registers every ctx_* tool with Pi", () => {
// Lifted out of each `it` so the path resolution lives in one place
// and a future MCP-entrypoint move only has to change one line.
const path = require("node:path") as typeof import("node:path");
const url = require("node:url") as typeof import("node:url");
const here = path.dirname(url.fileURLToPath(import.meta.url));
const mcpEntry = path.resolve(here, "..", "start.mjs");
const mcpEnv = { ...process.env, CONTEXT_MODE_DISABLE_VERSION_CHECK: "1" };
let bridge: { tools: string[]; shutdown: () => void } | null = null;
afterEach(() => {
if (bridge) {
bridge.shutdown();
bridge = null;
}
});
it("registers the canonical ctx_* tool set", async () => {
const registered: Array<{ name: string; label: string; description: string; parameters: unknown; execute: Function }> = [];
const fakePi = {
registerTool: (tool: any) => {
registered.push(tool);
},
};
const { bootstrapMCPTools } = await import("../src/adapters/pi/mcp-bridge.js");
bridge = await bootstrapMCPTools(fakePi, mcpEntry, { env: mcpEnv });
// Pin the canonical names — adding new MCP tools is fine
// (arrayContaining), but losing one of these is the bug regression.
expect(bridge.tools).toEqual(
expect.arrayContaining([
"ctx_execute",
"ctx_execute_file",
"ctx_search",
"ctx_index",
"ctx_batch_execute",
"ctx_fetch_and_index",
"ctx_doctor",
"ctx_stats",
"ctx_purge",
]),
);
// Each registration must satisfy the Pi contract.
for (const reg of registered) {
expect(reg.name).toMatch(/^ctx_/);
expect(reg.label).toBe(reg.name);
expect(typeof reg.description).toBe("string");
expect(reg.parameters).toBeTruthy();
expect(typeof reg.execute).toBe("function");
}
}, 30_000);
it("execute() round-trips through tools/call to the MCP server", async () => {
const registered: any[] = [];
const fakePi = {
registerTool: (tool: any) => registered.push(tool),
};
const { bootstrapMCPTools } = await import("../src/adapters/pi/mcp-bridge.js");
bridge = await bootstrapMCPTools(fakePi, mcpEntry, { env: mcpEnv });
const indexTool = registered.find((t) => t.name === "ctx_index");
expect(indexTool).toBeDefined();
const marker = `pi-bridge-marker-${process.pid}-${Date.now()}`;
const result = await indexTool.execute("test-call-1", {
content: `# heading\n\n${marker}\n`,
source: "pi-bridge-smoke",
});
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
// Server returns "Indexed N sections … from: pi-bridge-smoke" on
// success — pin the source label so a regression in tools/call
// arg-passing also fails this test.
expect(result.content[0].text).toMatch(/pi-bridge-smoke/);
expect(result.isError).toBeFalsy();
}, 30_000);
});
// ── Wiring: pi-extension.ts default export must call bootstrapMCPTools
//
// This is the regression that the rest of the suite does NOT catch: if
// a future refactor drops the `bootstrapMCPTools(pi, …)` call from
// src/adapters/pi/extension.ts but keeps the bridge module intact, every other
// bridge test stays green and the bug silently re-enters. We assert
// here that the extension's default export, after `_mcpBridgeReady`
// settles, has actually called `pi.registerTool` for at least the
// canonical ctx_* set.
describe("pi-extension.ts wiring (#426 regression guard)", () => {
it("registerPiExtension awaits bridge bootstrap and registers ctx_* via pi.registerTool", async () => {
const wireApi = createMockPiApi();
// PI_PROJECT_DIR / CLAUDE_PROJECT_DIR set inside registerPiExtension.
await registerPiExtension(wireApi, { projectDir: tempDir });
// Bootstrap is fire-and-forget on extension load — wait on the
// exported promise so the test does not race the spawn.
const mod = await import("../src/adapters/pi/extension.js");
await mod._mcpBridgeReady;
const calls = (wireApi.registerTool as any).mock.calls as Array<[any]>;
const registeredNames = calls.map(([t]) => t?.name).filter(Boolean);
// Same canonical pin as the bridge integration test — but reached
// through registerPiExtension instead of bootstrapMCPTools, so
// dropping the wiring fails this test even when the bridge module
// still works.
expect(registeredNames).toEqual(
expect.arrayContaining([
"ctx_execute",
"ctx_search",
"ctx_index",
"ctx_batch_execute",
"ctx_fetch_and_index",
]),
);
// Cleanup: SIGTERM the bridge child the wiring spawned so it does
// not leak past this test.
const sd = mod.default as any;
void sd; // silence unused
await wireApi._trigger("session_shutdown");
}, 30_000);
});
});
+21 -21
View File
@@ -16,7 +16,7 @@ import { randomUUID } from "node:crypto";
import { describe, it, expect, test, beforeAll, beforeEach, afterAll, afterEach, vi } from "vitest";
import { SessionDB } from "../../src/session/db.js";
import { OpenClawSessionDB } from "../../src/adapters/openclaw/session-db.js";
import { extractWorkspace, WorkspaceRouter } from "../../src/openclaw/workspace-router.js";
import { extractWorkspace, WorkspaceRouter } from "../../src/adapters/openclaw/workspace-router.js";
// MCP readiness sentinel — routing.mjs checks process.ppid in-process
const _sentinelDir = process.platform === "win32" ? tmpdir() : "/tmp";
@@ -121,7 +121,7 @@ function createMockApiFull() {
async function createTestPlugin(tempDir: string) {
// Each test gets an isolated DB by running from a unique cwd (tempDir).
// The plugin reads process.cwd() for projectDir — no fake env var needed.
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const mock = createMockApiFull();
await plugin.register(mock.api);
return mock;
@@ -209,7 +209,7 @@ describe("OpenClawPlugin", () => {
describe("object export", () => {
it("exports object with id, name, configSchema, register", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
expect(plugin.id).toBe("context-mode");
expect(plugin.name).toBe("Context Mode");
expect(plugin.configSchema).toBeDefined();
@@ -218,7 +218,7 @@ describe("OpenClawPlugin", () => {
});
it("configSchema has enabled property", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
expect(plugin.configSchema.properties.enabled).toBeDefined();
expect(plugin.configSchema.properties.enabled.type).toBe("boolean");
expect(plugin.configSchema.properties.enabled.default).toBe(true);
@@ -798,7 +798,7 @@ describe("Plugin exports", () => {
beforeEach(() => { vi.resetModules(); });
test("plugin exports id, name, configSchema, register", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
assert.equal(plugin.id, "context-mode");
assert.equal(plugin.name, "Context Mode");
assert.ok(plugin.configSchema);
@@ -810,7 +810,7 @@ describe("session_start hook", () => {
beforeEach(() => { vi.resetModules(); });
test("session_start hook is registered", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -820,7 +820,7 @@ describe("session_start hook", () => {
});
test("session_start hook is registered with no priority (void hook)", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -831,7 +831,7 @@ describe("session_start hook", () => {
});
test("session_start handler resets resumeInjected — verified via before_prompt_build sequence", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -861,7 +861,7 @@ describe("compaction hooks", () => {
beforeEach(() => { vi.resetModules(); });
test("before_compaction hook is registered", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -871,7 +871,7 @@ describe("compaction hooks", () => {
});
test("after_compaction hook is registered", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -917,7 +917,7 @@ describe("resume injection (before_prompt_build)", () => {
beforeEach(() => { vi.resetModules(); });
test("before_prompt_build resume hook is registered at priority 10", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1131,7 +1131,7 @@ describe("before_model_resolve hook", () => {
beforeEach(() => { vi.resetModules(); });
test("before_model_resolve hook is registered", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1150,7 +1150,7 @@ describe("before_model_resolve hook", () => {
});
test("before_model_resolve handler runs without throwing on decision message", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1165,7 +1165,7 @@ describe("before_model_resolve hook", () => {
});
test("before_model_resolve is silent when userMessage is empty", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1187,7 +1187,7 @@ describe("command lifecycle hooks", () => {
beforeEach(() => { vi.resetModules(); });
test("command:reset hook is registered", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, hooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1197,7 +1197,7 @@ describe("command lifecycle hooks", () => {
});
test("command:stop hook is registered", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, hooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1207,7 +1207,7 @@ describe("command lifecycle hooks", () => {
});
test("command:reset handler runs cleanupOldSessions without throwing", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, hooks } = createMockApiHooks();
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1226,7 +1226,7 @@ describe("verbose logging", () => {
beforeEach(() => { vi.resetModules(); });
test("plugin works without logger (logger is optional)", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api } = createMockApiHooks(false); // no logger
assert.doesNotThrow(() =>
@@ -1235,7 +1235,7 @@ describe("verbose logging", () => {
});
test("session_start emits info log when logger is provided", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks, logLines } = createMockApiHooks(true);
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1249,7 +1249,7 @@ describe("verbose logging", () => {
});
test("after_tool_call emits debug log for captured events when logger provided", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks, logLines } = createMockApiHooks(true);
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);
@@ -1268,7 +1268,7 @@ describe("verbose logging", () => {
});
test("before_prompt_build emits debug log when resume is injected", async () => {
const { default: plugin } = await import("../../src/openclaw-plugin.js");
const { default: plugin } = await import("../../src/adapters/openclaw/plugin.js");
const { api, typedHooks, logLines } = createMockApiHooks(true);
plugin.register(api as unknown as Parameters<typeof plugin.register>[0]);