fix: read project-scoped MCP registrations in gbrain detection (#2499)

Claude Code registers MCP servers at two scopes in ~/.claude.json: user
scope (.mcpServers) and project scope (.projects["/abs/path"].mcpServers
— what `claude mcp add` WITHOUT --scope user writes). Every gbrain
detection site read only user scope, so a correctly configured
project-scoped brain was invisible: brain-aware blocks suppressed,
remote-mode artifacts sync never recognised, and detectEndpointHash fell
through to the 'local' literal — two different project-scoped brains
hashed identically, so switching between them never invalidated the
cache, the exact scenario the function's docstring says it exists to
catch. Nothing errored; the features just quietly were not there.

Two sites fixed:

- scripts/resolvers/preamble/generate-brain-sync-block.ts: the shared
  detection block (rendered into every tier-2+ SKILL.md) now resolves the
  gbrain entry ONCE into _GBRAIN_MCP_ENTRY — user scope first, then the
  nearest-ancestor project entry for $PWD that actually carries a gbrain
  server (longest matching key with a path-boundary check: /a/repo never
  matches /a/repo2; a nested project WITHOUT gbrain doesn't shadow its
  parent's registration). _GBRAIN_MCP_TYPE and _GBRAIN_HOST extract from
  the resolved entry, so claude.json is parsed once per skill start. All
  SKILL.md files regenerated in this commit; the ship golden fixtures and
  three carve-guard skeleton caps (plan-eng-review, plan-devex-review,
  office-hours; ~1.5KB rendered growth per skill) are refreshed with
  measured values.
- bin/gstack-brain-cache detectEndpointHash: same resolution order in TS
  (user scope, else nearest-ancestor project entry by cwd, both path
  separators for Windows keys).

Tests: rendered-output tests in test/gen-skill-docs.test.ts pin the
regenerated block (static markers + a FUNCTIONAL run of the exact
rendered lines against a fixture ~/.claude.json with only a
project-scoped registration, plus an outside-cwd discriminator);
detectEndpointHash unit tests in test/brain-cache-roundtrip.test.ts cover
project-scope resolve, path-boundary, nearest-ancestor distinct hashes,
and user-scope precedence.

Root-cause analysis by @samporter-31 in #2499.

Fixes #2499

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 10:02:31 -07:00
co-authored by Claude Fable 5
parent 5854d122d3
commit acc354fcfa
57 changed files with 532 additions and 165 deletions
+51 -3
View File
@@ -126,13 +126,27 @@ function sha8(input: string): string {
* Detects the active brain endpoint (MCP URL or 'local') and returns its
* 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
* (.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.
*
* Params exist for tests; production callers use the defaults.
*/
export function detectEndpointHash(): string {
const claudeJsonPath = join(homedir(), '.claude.json');
export function detectEndpointHash(
claudeJsonPath: string = join(homedir(), '.claude.json'),
cwd: string = process.cwd(),
): string {
if (existsSync(claudeJsonPath)) {
try {
const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8'));
const gbrainServer = cfg?.mcpServers?.gbrain;
const gbrainServer = resolveGbrainMcpEntry(cfg, cwd);
const url = gbrainServer?.url || gbrainServer?.transport?.url;
if (typeof url === 'string' && url.length > 0) {
return sha8(url);
@@ -143,6 +157,40 @@ export function detectEndpointHash(): string {
return 'local';
}
interface McpEntryish {
url?: unknown;
transport?: { url?: unknown };
}
/**
* 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.
*/
function resolveGbrainMcpEntry(
cfg: unknown,
cwd: string,
): McpEntryish | undefined {
const root = cfg as {
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 };
}
return best?.entry;
}
// ──────────────────────────────────────────────────────────────────────────
// Atomic write (tmp + rename)
// ──────────────────────────────────────────────────────────────────────────