Skip to content

Commit 2079011

Browse files
authored
Merge pull request #10 from Eilodon/claude/code-analysis-optimization-9m6kdr
fix(mcp): stop racing SessionStart hook against ci server connection
2 parents d1f0070 + 5c7d6e6 commit 2079011

4 files changed

Lines changed: 195 additions & 17 deletions

File tree

.claude/hooks/ci-mcp-entrypoint.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
# MCP stdio entrypoint for the "ci" server (see .mcp.json). Ensures the
3+
# ci-cli binary exists, then execs it directly — instead of going through
4+
# `cargo run` on every single connection attempt.
5+
#
6+
# Why this exists (full story: docs/cloud-environment-setup.md):
7+
# Claude Code dials configured MCP servers asynchronously, with NO ordering
8+
# guarantee relative to SessionStart hooks completing (confirmed against
9+
# code.claude.com/docs/en/mcp + .../claude-code-on-the-web: MCP startup is
10+
# "non-blocking by default", and SessionStart hooks "typically fire before
11+
# servers finish connecting"). A failed initial connection gets at most 3
12+
# quick retries (~7s of total backoff, v2.1.121+) and is then marked failed
13+
# for the rest of the session — nowhere near enough to cover a cold
14+
# `cargo build` of this workspace (~59s measured: tree-sitter grammars +
15+
# stack-graphs + bundled SQLite). A SessionStart hook that pre-builds the
16+
# binary (session-start-build-ci.sh) can win that race but is NOT
17+
# guaranteed to — it runs concurrently with the connection attempt, not
18+
# strictly before it.
19+
#
20+
# Routing every connection through `cargo run` made this worse than it had
21+
# to be: every connect paid cargo's own freshness-check overhead, and any
22+
# connect could be silently upgraded into a full rebuild if cargo decided
23+
# one was needed — reintroducing the exact race this file exists to avoid.
24+
#
25+
# This script removes that variability: if a binary is already on disk,
26+
# exec it immediately, no cargo involved at all. It only builds here if the
27+
# binary is missing outright — a real fallback for local/non-cloud use
28+
# (no environment Setup Script exists there), NOT a substitute for one in
29+
# cloud sessions. The actual fix for the cold-start race is a Cloud
30+
# environment Setup Script that builds this binary once, before Claude Code
31+
# (and its MCP dialing) ever launches — see docs/cloud-environment-setup.md
32+
# for the exact script to paste into the environment settings UI. That
33+
# config lives outside this repo (Anthropic-side, not git-tracked), which
34+
# is exactly why it needs to be written down somewhere a future reader of
35+
# this repo can find it.
36+
set -uo pipefail
37+
38+
cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1
39+
40+
BIN="target/debug/ci"
41+
42+
if [ ! -x "$BIN" ]; then
43+
# Build output goes to stderr only — stdout is the MCP JSON-RPC channel,
44+
# and any stray text there would corrupt the handshake.
45+
cargo build --quiet -p ci-cli 1>&2
46+
fi
47+
48+
exec "$BIN" serve --project-root . "$@"

.claude/hooks/session-start-build-ci.sh

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
#!/usr/bin/env bash
2-
# SessionStart hook: pre-build the ci-cli binary synchronously so the "ci"
3-
# stdio MCP server (.mcp.json: `cargo run --quiet -p ci-cli -- serve ...`)
4-
# can finish its handshake inside the MCP client's fixed 30s connection
5-
# timeout.
2+
# SessionStart hook: best-effort pre-build of the ci-cli binary.
63
#
7-
# On a fresh checkout (empty target/), `cargo build -p ci-cli` compiles the
8-
# full dependency tree (tree-sitter grammars, rusqlite bundled, stack-graphs,
9-
# embeddings) — measured ~60s even with a warm crates.io registry cache,
10-
# over 2x the client's timeout, so `cargo run` in .mcp.json reliably times
11-
# out on the very first connection of every fresh session/container.
12-
# Once target/ is warm, `cargo run` reconnects in well under 1s (cargo's own
13-
# freshness check + exec), so paying the compile cost here — before the
14-
# session (and the MCP client's timer) starts — fixes it for the rest of
15-
# the session. Must stay synchronous: async mode would let the MCP connect
16-
# attempt race the build, which is the failure this hook exists to avoid.
4+
# CORRECTION (2026-07-02): the previous version of this comment claimed a
5+
# synchronous build here "fixes it for the rest of the session" — that is
6+
# false, confirmed against code.claude.com/docs/en/mcp and
7+
# .../claude-code-on-the-web. SessionStart hooks and MCP server connection
8+
# attempts are NOT ordered: MCP startup is "non-blocking by default" and
9+
# dials configured servers concurrently with hooks, not after them. A cold
10+
# `cargo build -p ci-cli` (~59s measured: tree-sitter grammars + stack-graphs
11+
# + bundled SQLite) can easily still be running when the "ci" server's
12+
# initial connection attempt times out — which gets at most 3 quick retries
13+
# (~7s total backoff, v2.1.121+) before Claude Code marks the server failed
14+
# for the rest of the session, with no further retry. This hook can *win*
15+
# that race, but cannot *guarantee* winning it — see
16+
# docs/cloud-environment-setup.md for why the real fix is a Cloud
17+
# environment Setup Script (runs once, before Claude Code launches at all,
18+
# cached across sessions), not anything that runs from SessionStart.
19+
#
20+
# This hook is still worth keeping for two reasons: (1) it's the only
21+
# mechanism available for local/non-cloud Claude Code, where there is no
22+
# environment Setup Script concept at all; (2) unlike
23+
# `ci-mcp-entrypoint.sh` (.mcp.json's actual entrypoint, which only builds
24+
# when the binary is *missing*), this always runs `cargo build`, so it also
25+
# catches the binary being *stale* — e.g. mid-session edits to ci's own
26+
# source in a prior session. Kept synchronous so that when it does win the
27+
# race, the win is real (no async handoff for the MCP dial to slip past).
1728
set -uo pipefail
1829

1930
if ! command -v cargo >/dev/null 2>&1; then
@@ -24,7 +35,7 @@ build_output=$(cargo build --quiet -p ci-cli 2>&1)
2435
build_status=$?
2536

2637
if [ "$build_status" -ne 0 ]; then
27-
jq -n --arg msg "ci-cli pre-build failed (exit $build_status) — the ci MCP server will likely fail to connect (30s client timeout). Build output:
38+
jq -n --arg msg "ci-cli pre-build failed (exit $build_status) — the ci MCP server will likely fail to connect. Build output:
2839
$build_output" \
2940
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $msg}}'
3041
fi

.mcp.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
},
1818
"ci": {
1919
"type": "stdio",
20-
"command": "cargo",
21-
"args": ["run", "--quiet", "-p", "ci-cli", "--", "serve", "--project-root", "."],
20+
"command": "bash",
21+
"args": [".claude/hooks/ci-mcp-entrypoint.sh"],
2222
"env": {}
2323
}
2424
}

docs/cloud-environment-setup.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Cloud environment setup — why the "ci" MCP server needs a Setup Script
2+
3+
This repo dogfoods its own MCP server: `.mcp.json` wires up a `ci` stdio
4+
server pointed at this workspace's own `ci-cli` binary. On Claude Code on
5+
the web, that binary does not exist until something compiles it — and
6+
where that compile happens determines whether the server ever connects.
7+
8+
## The failure this document exists to prevent
9+
10+
`ci-cli` is a Rust binary with a nontrivial dependency tree (tree-sitter
11+
grammars, stack-graphs, bundled SQLite). A cold `cargo build -p ci-cli` on
12+
a fresh checkout measures **~59s** in this environment. Claude Code's MCP
13+
client dials configured servers **concurrently with, not after,**
14+
`SessionStart` hooks — confirmed against the official docs:
15+
16+
- [code.claude.com/docs/en/mcp](https://code.claude.com/docs/en/mcp)
17+
MCP server startup is "non-blocking by default"; a failed initial
18+
connection gets at most 3 retries on transient errors (~7s of total
19+
backoff, as of v2.1.121), then the server is marked failed **for the
20+
rest of the session**, with no further retry.
21+
- [code.claude.com/docs/en/claude-code-on-the-web](https://code.claude.com/docs/en/claude-code-on-the-web)
22+
`SessionStart` hooks "run after Claude Code launches, on every session,"
23+
which is a different (and unordered, relative to MCP dialing) point in
24+
startup than a Setup Script, which runs "before Claude Code launches."
25+
26+
A `SessionStart` hook that runs `cargo build` (this repo has one —
27+
`.claude/hooks/session-start-build-ci.sh`) can therefore **win** the race
28+
against a cold connection attempt, but cannot **guarantee** winning it — a
29+
59s build has no trouble outlasting a ~7s retry budget. A previous version
30+
of this repo's setup relied on that hook alone and believed it had fixed
31+
the problem; it hadn't — it had just usually won the race, until a session
32+
where it didn't.
33+
34+
## The actual fix: a Cloud environment Setup Script
35+
36+
Setup Scripts and `SessionStart` hooks look similar but solve different
37+
problems:
38+
39+
| | Setup Script | `SessionStart` hook |
40+
|---|---|---|
41+
| Runs | Once, **before** Claude Code (and MCP dialing) launches at all | Every session, **after** Claude Code launches, concurrently with MCP dialing |
42+
| Configured in | Cloud environment settings UI (not in this repo) | `.claude/settings.json` (this repo, `.claude/hooks/session-start-build-ci.sh`) |
43+
| Output persistence | Filesystem snapshotted and reused for ~7 days, or until the script/network config changes | None — runs fresh every session |
44+
45+
Only the Setup Script runs early enough to structurally rule out the race.
46+
Because it's environment-level config, it is **not stored in this repo**
47+
that's exactly why it needs to be written down here, or the next person to
48+
hit this failure has no way to discover it.
49+
50+
### What to paste in
51+
52+
Open the environment settings dialog (cloud icon → environment selector →
53+
settings icon) for the environment used to run sessions against this repo,
54+
and put this in the **Setup script** field:
55+
56+
```bash
57+
#!/bin/bash
58+
cd "$(dirname "$0")" 2>/dev/null || true
59+
cargo build --quiet -p ci-cli
60+
```
61+
62+
(Setup scripts run with the repo checked out as the working directory, so
63+
a plain `cargo build --quiet -p ci-cli` without the `cd` is equally fine —
64+
the `cd` guard above is only there in case that assumption ever changes.)
65+
66+
This must build to the **same path** `.mcp.json` expects:
67+
`target/debug/ci` (debug, not `--release` — release compiles slower for no
68+
benefit here, since this binary is a local dev/dogfood tool, not a
69+
distributed artifact; keep this in sync with
70+
`.claude/hooks/session-start-build-ci.sh` and
71+
`.claude/hooks/ci-mcp-entrypoint.sh` if that ever changes).
72+
73+
Keep it under Claude Code's ~5-minute Setup Script budget — 59s measured
74+
leaves a wide margin.
75+
76+
### Optional extra margin: `MCP_TIMEOUT`
77+
78+
`MCP_TIMEOUT` (milliseconds) controls the MCP client's own initial
79+
connection timeout. It is a process-level environment variable for the
80+
`claude` process itself (e.g. `MCP_TIMEOUT=10000 claude` locally) — **not**
81+
a per-server `.mcp.json` field, and **not** the same as `.mcp.json`'s
82+
per-server `timeout` field (that one bounds individual tool *calls* after
83+
connection, not the initial handshake). For cloud sessions, the
84+
equivalent lever is adding `MCP_TIMEOUT=120000` as an **environment
85+
variable** in the same environment settings dialog as the Setup Script.
86+
This is optional defense-in-depth (matters mainly for the very first
87+
session before any cache exists, or right after the ~7-day cache expiry)
88+
— the Setup Script is what actually fixes the steady state.
89+
90+
## What's already handled in this repo (defense in depth, not a substitute)
91+
92+
- `.claude/hooks/ci-mcp-entrypoint.sh``.mcp.json`'s actual entrypoint
93+
now. Execs the pre-built binary directly if present (no `cargo`
94+
involved, no risk of an unexpected rebuild reopening this exact race);
95+
only builds inline if the binary is missing outright.
96+
- `.claude/hooks/session-start-build-ci.sh` — still runs every session.
97+
Redundant with the Setup Script in the common case (no-op if `target/`
98+
is already warm), but it's what keeps the binary from going *stale*
99+
(e.g. after editing `ci`'s own source), and it's the only mechanism at
100+
all for local/non-cloud Claude Code, which has no Setup Script concept.
101+
102+
None of this replaces the Setup Script for cloud sessions — it narrows the
103+
window and covers the cases the Setup Script can't (local dev, staleness),
104+
but only the Setup Script removes the compile step from the connection
105+
race entirely.
106+
107+
## How to verify it worked
108+
109+
At the start of a session, once indexing has had a moment to run:
110+
111+
```
112+
mcp__ci__repo_overview()
113+
```
114+
115+
If this resolves (rather than the tool being entirely absent from your
116+
tool list), the connection succeeded. If it's missing, check for a `ci-cli
117+
pre-build failed` message in the session's `SessionStart` hook output —
118+
that means the fallback hook itself failed (e.g. a real compile error),
119+
which is a different problem than the timing race this document covers.

0 commit comments

Comments
 (0)