Skip to content

Regression in #230: PPID mismatch — mcpRedirect swallows all redirects when hook spawned via shell wrapper #347

@se7enango

Description

@se7enango

Summary

The MCP-readiness sentinel guard introduced in 04569b1 (fix for #230) is silently bypassed in normal Claude Code sessions because the MCP server and the hook process resolve process.ppid to different PIDs. As a result, isMCPReady() always returns false for hooks, and mcpRedirect() swallows every deny/modify decision — WebFetch, curl/wget, inline-HTTP, and gradle/maven redirects all become silent passthroughs.

Effectively, the core routing policy of context-mode is no-op for any Claude Code installation that spawns hooks via a shell wrapper.

Root cause

src/server.ts (MCP server) writes the sentinel using its own process.ppid:

// src/server.ts (introduced by #230)
const mcpSentinel = join(tmpdir(), `context-mode-mcp-ready-${process.ppid}`);
// ...
writeFileSync(mcpSentinel, String(process.pid));

hooks/core/mcp-ready.mjs reads the sentinel using its own process.ppid:

export function sentinelPath() {
  return resolve(tmpdir(), `context-mode-mcp-ready-${process.ppid}`);
}

This only works if both processes share the same parent. They don't:

Process How spawned process.ppid
MCP server Claude Code → node start.mjs (direct) Claude Code main PID
Hook (pretooluse.mjs) Claude Code → bash -c \"node hooks/pretooluse.mjs\" Transient bash PID

So the sentinel exists at /tmp/context-mode-mcp-ready-${CC_PID}, but the hook looks for /tmp/context-mode-mcp-ready-${TRANSIENT_BASH_PID} — different file, never found.

Reproduction

Environment:

  • context-mode v1.0.94 (installed via Claude Code marketplace, autoUpdate: true)
  • Claude Code CLI on Linux (WSL2, Ubuntu)

Steps:

  1. Start a Claude Code session with context-mode plugin enabled.
  2. Verify the MCP server is alive and the sentinel exists:
    ls /tmp/context-mode-mcp-ready-*
    # → /tmp/context-mode-mcp-ready-<CC_PID>   (PID inside is alive)
    
  3. Trigger any redirect from agent (e.g. ask agent to call WebFetch).
  4. Observed: WebFetch succeeds. No deny shown.
  5. Expected: deny with permissionDecision: \"deny\" and the Use ctx_fetch_and_index ... reason.

Direct verification of the guard:

node -e \"
import('/path/to/plugins/cache/context-mode/context-mode/1.0.94/hooks/core/mcp-ready.mjs').then(m => {
  console.log('ppid:', process.ppid);
  console.log('sentinelPath:', m.sentinelPath());
  console.log('isMCPReady:', m.isMCPReady());
});
\"

Output (run from a child of Claude Code):

ppid: 1067770
sentinelPath: /tmp/context-mode-mcp-ready-1067770
isMCPReady: false

While the actual sentinel sits at /tmp/context-mode-mcp-ready-1034965 (Claude Code's main PID), pointing to the live MCP server PID 1035016.

End-to-end confirmation — manually invoking the hook with a WebFetch payload returns empty stdout (passthrough):

echo '{\"tool_name\":\"WebFetch\",\"tool_input\":{\"url\":\"https://example.com\",\"prompt\":\"x\"},\"session_id\":\"t\"}' \\
  | node hooks/pretooluse.mjs
# → (no output)   ← should print {hookSpecificOutput: {permissionDecision: \"deny\", ...}}

And the routing function directly:

node -e \"
import('hooks/core/routing.mjs').then(m => {
  const d = m.routePreToolUse('WebFetch', {url:'https://example.com'}, '/tmp', 'claude-code', 't');
  console.log(d);
});\"
# → null   ← should be { action: 'deny', reason: '...' }

Impact (all affected by the same swallow)

  • WebFetch deny (routing.mjs:313-320)
  • curl/wget redirect-to-execute (routing.mjs:255-263)
  • inline-HTTP (fetch(...), requests.get, http.get) redirect (routing.mjs:274-285)
  • gradle / mvn build-tool redirect (routing.mjs:289-297)

All of these become silent no-ops, so the agent quietly uses original tools and floods the context window — the exact thing context-mode is designed to prevent.

ctx_doctor reports green across the board because it only checks file existence, not behavior.

Suggested fixes

Any of these would resolve the PPID mismatch. Listed roughly in increasing invasiveness:

  1. Directory-scan + PID liveness probe — drop the per-PPID filename. MCP server writes a fixed-path sentinel ${tmpdir}/context-mode-mcp-ready/<server-pid>. Hook scans the directory and returns true if any file's PID is alive (process.kill(pid, 0)).
  2. Lockfile + stat — server holds an exclusive lockfile at a fixed path; hook checks lock holder's PID liveness. Same idea, OS-level primitive.
  3. Environment-variable handshake — MCP server export CTX_MODE_MCP_PID=<pid> into the session somehow (or write a dotenv next to plugin.json that hooks source). Less portable.
  4. Walk up the process tree — hook walks up its parent chain (/proc/<pid>/status PPID lines on Linux, ps -o ppid= elsewhere) until it finds a PID that has a matching sentinel file. Works on any spawn topology.

Option 1 looks cleanest and avoids the cross-platform PPID resolution rabbit hole.

Workarounds

For users who want the redirects back today: pin to a version prior to 04569b1 (2026-04-13) and disable marketplace autoUpdate. The mcpRedirect guard is the only thing producing the regression — earlier versions called the redirect formatter directly.

Notes

  • Reproduced under Claude Code on Linux (WSL2, Ubuntu). Other Claude Code platforms that wrap hook commands in bash -c \"...\" will behave the same. Direct-spawn platforms (e.g. some Cursor/VSCode adapters) may coincidentally work because their hook spawn topology happens to share a PPID with the MCP server.
  • Issue Agent gets stuck when WebFetch is blocked but ctx_fetch_and_index MCP tool is unavailable #230's original problem was real (deny with no MCP fallback → agent stuck). A directory-scan fix preserves the graceful-degradation intent without the ppid coupling.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions