diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 559512e02..927fd52b7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 => { 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 */ diff --git a/test/mcp-stdio-source-resolution.test.ts b/test/mcp-stdio-source-resolution.test.ts new file mode 100644 index 000000000..50a8c348d --- /dev/null +++ b/test/mcp-stdio-source-resolution.test.ts @@ -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 (sql: string, params?: unknown[]): Promise => { + 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' }); + }); + }); +});