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
+9 -4
View File
@@ -166,7 +166,12 @@ describe('brain-cache endpoint detection', () => {
expect(outer).not.toBe('local');
});
test('detectEndpointHash still prefers user scope over project scope (#2499)', async () => {
test('detectEndpointHash prefers project-local scope over user scope (#2392 wave)', async () => {
// Empirically verified against claude 2.1.233 with hermetic fixtures:
// `claude mcp get gbrain` reports "Scope: Local config" when both scopes
// define the server — project-local WINS. The old pin here encoded the
// opposite (user-first) assumption, which mis-hashed endpoints whenever
// the two scopes disagreed.
const mod = await importCache();
const cj = join(TMP_HOME, 'claude.json');
writeFileSync(cj, JSON.stringify({
@@ -175,14 +180,14 @@ describe('brain-cache endpoint detection', () => {
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
},
}));
const userScoped = mod.detectEndpointHash(cj, '/w/repo');
// Same file minus the user-scope entry → different hash proves user scope won.
const conflictHash = mod.detectEndpointHash(cj, '/w/repo');
// Same file minus the USER entry → identical hash proves project scope won.
writeFileSync(cj, JSON.stringify({
projects: {
'/w/repo': { mcpServers: { gbrain: { url: 'https://proj.example/mcp' } } },
},
}));
expect(mod.detectEndpointHash(cj, '/w/repo')).not.toBe(userScoped);
expect(mod.detectEndpointHash(cj, '/w/repo')).toBe(conflictHash);
});
});
+60 -1
View File
@@ -11,7 +11,10 @@
* Gate-tier, free, pure import + assertion. Runs in <100ms.
*/
import { describe, test, expect } from 'bun:test';
import { describe, test, expect, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
BRAIN_CACHE_ENTITIES,
SKILL_DIGEST_SUBSETS,
@@ -167,3 +170,59 @@ describe('brain-cache-spec internal consistency', () => {
expect(getPreflightSkills().sort()).toEqual(expected.sort());
});
});
describe('brain-cache MCP scope precedence (C15 pin)', () => {
// Claude Code resolves a same-name MCP conflict in favor of the
// PROJECT-LOCAL entry (.projects[cwd].mcpServers) over the user-scope
// entry (.mcpServers). Verified empirically against claude 2.1.233 with a
// hermetic fake $HOME: `claude mcp get gbrain` reported "Scope: Local
// config" and the project-local URL when both scopes defined gbrain.
// detectEndpointHash must hash the endpoint the project actually talks
// to, or a brain switch would never invalidate the cache.
const TMP = mkdtempSync(join(tmpdir(), 'brain-cache-precedence-'));
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
const cache = () => import('../bin/gstack-brain-cache');
const writeFixture = (name: string, cfg: object): string => {
const p = join(TMP, name);
writeFileSync(p, JSON.stringify(cfg));
return p;
};
const USER_URL = { type: 'http', url: 'https://user.example/mcp' };
const PROJ_URL = { type: 'http', url: 'https://proj.example/mcp' };
test('project-local gbrain entry beats user scope for a cwd inside the project', async () => {
const mod = await cache();
const conflict = writeFixture('claude-conflict.json', {
mcpServers: { gbrain: USER_URL },
projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
});
const conflictHash = mod.detectEndpointHash(conflict, '/w/repo/src');
// Same hash as the project entry alone → the project-local entry won.
const projOnly = writeFixture('claude-proj-only.json', {
projects: { '/w/repo': { mcpServers: { gbrain: PROJ_URL } } },
});
expect(conflictHash).toBe(mod.detectEndpointHash(projOnly, '/w/repo/src'));
// And NOT the user entry's hash.
const userOnly = writeFixture('claude-user-only.json', {
mcpServers: { gbrain: USER_URL },
});
expect(conflictHash).not.toBe(mod.detectEndpointHash(userOnly, '/w/repo/src'));
});
test('user scope still resolves when the cwd has no project-local entry', async () => {
const mod = await cache();
const cj = writeFixture('claude-user-fallback.json', {
mcpServers: { gbrain: USER_URL },
projects: { '/other/repo': { mcpServers: { gbrain: PROJ_URL } } },
});
const hash = mod.detectEndpointHash(cj, '/w/unrelated');
expect(hash).toHaveLength(8);
// Matches the user-only hash — the OTHER project's entry is invisible
// outside its own tree.
const userOnly = writeFixture('claude-user-only-2.json', {
mcpServers: { gbrain: USER_URL },
});
expect(hash).toBe(mod.detectEndpointHash(userOnly, '/w/unrelated'));
});
});
+116 -2
View File
@@ -594,13 +594,16 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped (#2499)", () => {
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped for THIS cwd (#2499)", () => {
// The project key must be the running process's cwd (or an ancestor):
// per-project scoping (C15) means only registrations visible to this
// cwd count.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { "/some/repo": { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
projects: { [process.cwd()]: { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
@@ -659,6 +662,117 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
// ── C15: project scan is scoped to the cwd's nearest-ancestor project ──
// Before the fix, hasRemoteOnlyGbrainMcp scanned EVERY project's
// mcpServers, so one project's remote registration reclassified broken
// local engines as thin-client machine-wide.
it("C15: an OTHER project's remote entry no longer flips thin-client for this cwd (no config)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { "/some/other/repo": { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
it("C15: an OTHER project's remote entry no longer reclassifies a broken local engine", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: {
projects: { "/some/other/repo": { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
});
it("C15: path boundary — a sibling-prefix project key is NOT this cwd's project", () => {
// /path/to/repo2 must never match a scan from /path/to/repo (and vice
// versa) — same boundary rule as the jq resolver and brain-cache.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { [`${process.cwd()}-sibling`]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
it("C15: an ANCESTOR project key of this cwd still counts (nearest-ancestor matching)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: { [dirname(process.cwd())]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("C15: a nearer project WITHOUT gbrain does not shadow an ancestor's registration (jq parity)", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "ok",
withConfig: false,
claudeJson: {
projects: {
[dirname(process.cwd())]: { mcpServers: { gbrain: REMOTE_GBRAIN } },
[process.cwd()]: { mcpServers: { "other-server": { type: "http", url: "https://x.example/mcp" } } },
},
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
// ── C15: adopted precedence — project-local beats user scope per name ──
// Claude Code's own conflict resolution, verified empirically against
// claude 2.1.233 with a hermetic fake $HOME (`claude mcp get gbrain`
// reports "Scope: Local config" when both scopes define the name).
it("C15 precedence: THIS project's remote gbrain shadows a user-scope local-stdio gbrain → thin-client", () => {
// Union semantics would see the user-scope stdio entry and keep
// engine-locked; the adopted precedence says this project's queries go
// remote, so thin-client is the truthful classification here.
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: {
mcpServers: { gbrain: LOCAL_GBRAIN },
projects: { [process.cwd()]: { mcpServers: { gbrain: REMOTE_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("C15 precedence: THIS project's local-stdio gbrain shadows a user-scope remote gbrain → local statuses keep their meaning", () => {
env = makeEnv({
withGbrain: true,
gbrainBehavior: "engine-locked",
withConfig: true,
claudeJson: {
mcpServers: { gbrain: REMOTE_GBRAIN },
projects: { [process.cwd()]: { mcpServers: { gbrain: LOCAL_GBRAIN } } },
},
});
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
});
it("--is-ok exits 0 on a bearer thin-client fixture (end-to-end gate)", () => {
env = makeEnv({
withGbrain: true,