fix(mcp): honor dotfile source for stdio serve (#3906) (#3965)

Wave-assembled from PR #3965 by @javieraldape.

Co-Authored-By: javieraldape <6430563+javieraldape@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-08-12 14:38:36 -07:00
committed by Sina Matian
co-authored by javieraldape
parent d8e3772810
commit 3f595083fe
2 changed files with 102 additions and 21 deletions
+29 -21
View File
@@ -21,6 +21,23 @@ import { gcSessionContextState } from '../core/context/session-state.ts';
import { makeContextPackIpcHandler } from './context-pack-handler.ts';
import { logTurnContextDeliveryFireAndForget } from '../core/context/volunteer-events.ts';
export async function resolveMcpStdioSourceScope(
engine: BrainEngine,
cwd: string = process.cwd(),
): Promise<{ sourceId: string; localFederatedSourceIds?: string[] }> {
try {
const { resolveSourceWithTier, localFederatedSourceIds } = await import('../core/source-resolver.ts');
const resolved = await resolveSourceWithTier(engine, null, cwd);
const federated = await localFederatedSourceIds(engine, resolved.source_id, resolved.tier);
return {
sourceId: resolved.source_id,
...(federated ? { localFederatedSourceIds: federated } : {}),
};
} catch {
return { sourceId: process.env.GBRAIN_SOURCE || 'default' };
}
}
export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpSurface } = {}) {
const server = new Server(
{ name: 'gbrain', version: VERSION },
@@ -48,20 +65,11 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS
// shape and cast through `any` (the SDK accepts it via the ServerResult union).
server.setRequestHandler(CallToolRequestSchema, async (request: any): Promise<any> => {
const { name, arguments: params } = request.params;
// #3242: when the operator didn't pin a source via GBRAIN_SOURCE, stdio
// reads span every `config.federated = true` source (same visibility set
// as unqualified local CLI reads). GBRAIN_SOURCE set = explicit scope,
// no widening. Best-effort: a resolver failure keeps the scalar scope.
// ponytail: one tiny SELECT per tool call; cache it if it ever shows up.
let localFederated: string[] | undefined;
try {
const { localFederatedSourceIds } = await import('../core/source-resolver.ts');
localFederated = await localFederatedSourceIds(
engine,
process.env.GBRAIN_SOURCE || 'default',
process.env.GBRAIN_SOURCE ? 'env' : 'seed_default',
);
} catch { /* scalar scope stands */ }
// #3242 / #3906: stdio resolves its source through the same ambient chain
// as local CLI dispatch: GBRAIN_SOURCE, then .gbrain-source, then the
// non-explicit fallback tiers. Non-explicit tiers may widen to federated
// local reads; explicit/env/dotfile scopes stay scalar.
const sourceScope = await resolveMcpStdioSourceScope(engine);
// v0.28: stdio MCP has no per-token auth (local pipe). Default the
// takes-holder allow-list to ['world'] so agent-facing callers don't
// see private hunches via takes_list / takes_search / query. Operators
@@ -83,11 +91,10 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS
transport: 'stdio',
takesHoldersAllowList: ['world'],
...(sessionId ? { sessionId } : {}),
// v0.31: source defaults to 'default' for stdio (no per-token scope).
// Operators who want a different source on stdio MCP should set
// GBRAIN_SOURCE in the env or use --source via `gbrain call`.
sourceId: process.env.GBRAIN_SOURCE || 'default',
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
sourceId: sourceScope.sourceId,
...(sourceScope.localFederatedSourceIds
? { localFederatedSourceIds: sourceScope.localFederatedSourceIds }
: {}),
// v0.31 (eD3): _meta.brain_hot_memory injection so Claude Desktop /
// Code see the brain's relevant hot memory automatically alongside
// every tool-call response. Best-effort; absorbs errors.
@@ -112,7 +119,7 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS
const cfg = loadConfig();
if (cfg?.engine === 'pglite' && cfg.database_path) {
resolveSocket = resolveSocketPath(cfg.database_path);
const defaultSource = process.env.GBRAIN_SOURCE || 'default';
const { sourceId: defaultSource } = await resolveMcpStdioSourceScope(engine);
// [S3#6] turn_context requires the shared secret from the data dir
// (created 0600 here if absent). If the secret can't be provisioned,
// turn_context stays fail-closed ('unauthorized') while the secret-free
@@ -194,8 +201,9 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS
let startupSweep: { cancel: () => void } | null = null;
try {
const { armStartupSweep } = await import('../core/sweep.ts');
const { sourceId } = await resolveMcpStdioSourceScope(engine);
startupSweep = armStartupSweep(engine, {
sourceId: process.env.GBRAIN_SOURCE || 'default',
sourceId,
});
} catch {
/* startup sweep is best-effort; never block serve */
+73
View File
@@ -0,0 +1,73 @@
import { describe, test, expect, afterEach } from 'bun:test';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { BrainEngine } from '../src/core/engine.ts';
import { resolveMcpStdioSourceScope } from '../src/mcp/server.ts';
import { withEnv } from './helpers/with-env.ts';
function makeEngine(registeredSources: string[]): BrainEngine {
return {
kind: 'pglite',
executeRaw: async <T>(sql: string, params?: unknown[]): Promise<T[]> => {
if (sql.includes('SELECT id FROM sources WHERE id = $1')) {
const id = params?.[0];
return (typeof id === 'string' && registeredSources.includes(id)
? [{ id } as T]
: []);
}
if (sql.includes('SELECT id, local_path FROM sources')) return [];
if (sql.includes('SELECT id, config, archived FROM sources')) {
return registeredSources.map(id => ({ id, config: null, archived: false }) as T);
}
return [];
},
getConfig: async () => null,
} as unknown as BrainEngine;
}
function writeSourceDotfile(dir: string, sourceId: string): void {
const path = join(dir, '.gbrain-source');
writeFileSync(path, `${sourceId}\n`);
chmodSync(path, 0o600);
}
describe('stdio MCP source resolution', () => {
const scratchDirs: string[] = [];
afterEach(() => {
for (const dir of scratchDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
test('honors .gbrain-source when GBRAIN_SOURCE is absent', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-mcp-source-'));
scratchDirs.push(dir);
writeSourceDotfile(dir, 'team-alpha');
await withEnv({ GBRAIN_SOURCE: undefined }, async () => {
const scope = await resolveMcpStdioSourceScope(
makeEngine(['default', 'team-alpha']),
dir,
);
expect(scope).toEqual({ sourceId: 'team-alpha' });
});
});
test('GBRAIN_SOURCE wins over .gbrain-source', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-mcp-source-'));
scratchDirs.push(dir);
writeSourceDotfile(dir, 'team-alpha');
await withEnv({ GBRAIN_SOURCE: 'env-source' }, async () => {
const scope = await resolveMcpStdioSourceScope(
makeEngine(['default', 'team-alpha', 'env-source']),
dir,
);
expect(scope).toEqual({ sourceId: 'env-source' });
});
});
});