fix(gbrain-status): MCP scoping is per-project, and project-local beats user scope

hasRemoteOnlyGbrainMcp scanned EVERY project's mcpServers in ~/.claude.json,
so one project's remote gbrain registration reclassified broken local engines
as thin-client machine-wide. It now reads user scope plus only the cwd's
nearest-ancestor project key.

The precedence itself was verified empirically and hermetically (fake HOME +
CLAUDE_CONFIG_DIR fixtures, claude 2.1.233): with both scopes defining
gbrain, 'claude mcp get gbrain' reports Scope: Local config — PROJECT-LOCAL
WINS. Both in-repo consumers assumed the opposite; brain-cache's endpoint
resolution flips to nearest-ancestor-project-first, and the stale user-first
pin in brain-cache-roundtrip now pins the verified precedence. (The user-first
jq in the brain-sync preamble resolver gets the same swap in the template
block.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-17 10:43:54 -07:00
co-authored by Claude Fable 5
parent 6955dfa348
commit d8a207fdd7
5 changed files with 272 additions and 48 deletions
+30 -23
View File
@@ -127,15 +127,19 @@ function sha8(input: string): string {
* stable identity hash. Used to detect when the user switches brains
* (different endpoint → different cache).
*
* Reads BOTH registration scopes in ~/.claude.json (#2499): user scope
* (.mcpServers.gbrain) first, then project scope
* Reads BOTH registration scopes in ~/.claude.json (#2499): project scope
* (.projects["/abs/path"].mcpServers.gbrain — what `claude mcp add`
* WITHOUT --scope user writes), preferring the nearest ancestor of cwd
* (longest matching project key) so nested repos resolve to their own
* brain. Before the project-scope read, two different project-scoped
* brains both hashed to 'local', so switching between them never
* invalidated the cache — the exact scenario this function exists to
* catch.
* WITHOUT --scope user writes) first, preferring the nearest ancestor of
* cwd (longest matching project key) so nested repos resolve to their own
* brain, then user scope (.mcpServers.gbrain) as the fallback. That order
* is Claude Code's own name-conflict precedence (local beats user) —
* verified empirically against claude 2.1.233 with a hermetic fake $HOME:
* `claude mcp get gbrain` reports "Scope: Local config" and the
* project-local URL when both scopes define the name — so the hash tracks
* the endpoint the project actually talks to. Before the project-scope
* read, two different project-scoped brains both hashed to 'local', so
* switching between them never invalidated the cache — the exact scenario
* this function exists to catch.
*
* Params exist for tests; production callers use the defaults.
*/
@@ -163,9 +167,11 @@ interface McpEntryish {
}
/**
* User-scope gbrain entry, else the nearest-ancestor project-scope entry
* for cwd (#2499). Path-boundary-aware: /a/repo never matches /a/repo2.
* Both separators are accepted so Windows project keys resolve.
* Nearest-ancestor project-scope gbrain entry for cwd, else the user-scope
* entry (#2499). Project-local first — Claude Code's own precedence for a
* same-name conflict (see detectEndpointHash's docstring for the empirical
* evidence). Path-boundary-aware: /a/repo never matches /a/repo2. Both
* separators are accepted so Windows project keys resolve.
*/
function resolveGbrainMcpEntry(
cfg: unknown,
@@ -175,20 +181,21 @@ function resolveGbrainMcpEntry(
mcpServers?: Record<string, McpEntryish>;
projects?: Record<string, { mcpServers?: Record<string, McpEntryish> }>;
} | null;
if (root?.mcpServers?.gbrain) return root.mcpServers.gbrain;
const projects = root?.projects;
if (!projects || typeof projects !== 'object') return undefined;
let best: { key: string; entry: McpEntryish } | undefined;
for (const [key, val] of Object.entries(projects)) {
if (!val || typeof val !== 'object') continue;
const entry = val.mcpServers?.gbrain;
if (!entry || typeof entry !== 'object') continue;
const isAncestor =
cwd === key || cwd.startsWith(`${key}/`) || cwd.startsWith(`${key}\\`);
if (!isAncestor) continue;
if (!best || key.length > best.key.length) best = { key, entry };
if (projects && typeof projects === 'object') {
let best: { key: string; entry: McpEntryish } | undefined;
for (const [key, val] of Object.entries(projects)) {
if (!val || typeof val !== 'object') continue;
const entry = val.mcpServers?.gbrain;
if (!entry || typeof entry !== 'object') continue;
const isAncestor =
cwd === key || cwd.startsWith(`${key}/`) || cwd.startsWith(`${key}\\`);
if (!isAncestor) continue;
if (!best || key.length > best.key.length) best = { key, entry };
}
if (best) return best.entry;
}
return best?.entry;
return root?.mcpServers?.gbrain;
}
// ──────────────────────────────────────────────────────────────────────────