Files
gbrain/src/mcp/server.ts
T
4b38724aa2 fix(sources): federated-source pages visible to get_page/list_pages/resolve_slugs and no-grant MCP callers (#3242) (#3301)
Pages ingested into a config.federated=true source were invisible to
normal reads: get_page/list_pages scoped to the scalar resolved source
('default'), while the fully UNSCOPED resolve_slugs leaked every
source's slugs — the reporter's exact observation matrix.

- federatedSearchScope now backs get_page, list_pages and resolve_slugs
  (not just search/query), so the unqualified read surface shares one
  visibility set: grant > federated set > scalar source. resolve_slugs
  gains the missing sourceScopeOpts-family scoping (leak sealed).
- The widening gate is now field-presence instead of ctx.remote:
  localFederatedSourceIds is populated only by server-side transports
  (never from caller params), so trust stays fail-closed while the
  stdio MCP transport (no GBRAIN_SOURCE) and the legacy HTTP token
  path (no operator-set permissions.source_id grant) can opt their
  unqualified callers into the operator-configured federated set.
  Tokens WITH a grant, per-call source_id, and OAuth allowedSources
  all still win and never widen.
- gbrain sync now attributes its ingest-log row to the synced source
  instead of the shared 'default' bucket (attribution sub-bug).

No engine SQL changes: getPage/listPages/resolveSlugs already accept
sourceIds[] in both engines (#1393/#876).

Fixes #3242

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:38 -07:00

168 lines
7.8 KiB
TypeScript

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import type { BrainEngine } from '../core/engine.ts';
import { operations } from '../core/operations.ts';
import { VERSION } from '../version.ts';
import { buildToolDefs } from './tool-defs.ts';
import { dispatchToolCall, validateParams, buildOperationContext } from './dispatch.ts';
import { getBrainHotMemoryMeta } from '../core/facts/meta-hook.ts';
import { loadConfig } from '../core/config.ts';
import {
resolveSocketPath,
startResolveIpcServer,
cleanupStaleSocket,
} from '../core/context/resolve-ipc.ts';
import { resolveEntitiesToPointers, logDeliveredReflexPointers } from '../core/context/retrieval-reflex.ts';
export async function startMcpServer(engine: BrainEngine) {
const server = new Server(
{ name: 'gbrain', version: VERSION },
{ capabilities: { tools: {} } },
);
// Generate tool definitions from operations. Extracted to buildToolDefs so
// the subagent tool registry (v0.15+) can call the same mapper against a
// filtered OPERATIONS subset instead of duplicating this shape.
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: buildToolDefs(operations),
}));
// Dispatch tool calls via shared dispatch.ts (parity with HTTP transport).
// MCP stdio callers are remote/untrusted; dispatch defaults remote=true.
// The MCP SDK's response type widened in 1.29 to allow a managed-task wrapper;
// gbrain ops are synchronous, so we return the legacy `{ content, isError? }`
// 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 */ }
// 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
// who want stdio to see everything should call ops directly via
// `gbrain call <op>` (sets remote=false in src/cli.ts).
return dispatchToolCall(engine, name, params, {
remote: true,
// #1061: mark the transport so whoami can report {transport: 'stdio'}
// instead of throwing unknown_transport. Trust posture unchanged —
// stdio stays remote/untrusted.
transport: 'stdio',
takesHoldersAllowList: ['world'],
// 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 } : {}),
// 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.
metaHook: getBrainHotMemoryMeta,
});
});
const transport = new StdioServerTransport();
await server.connect(transport);
// Retrieval Reflex (#1981, D9=C): on a PGLite brain, serve owns the single
// connection, so the context engine resolves salient entities THROUGH us over
// a local unix socket rather than opening a second (impossible) connection.
// Best-effort; failure to bind never blocks the MCP server.
let resolveServer: import('node:net').Server | null = null;
let resolveSocket: string | null = null;
try {
const cfg = loadConfig();
if (cfg?.engine === 'pglite' && cfg.database_path) {
resolveSocket = resolveSocketPath(cfg.database_path);
const defaultSource = process.env.GBRAIN_SOURCE || 'default';
resolveServer = await startResolveIpcServer(
resolveSocket,
(req) =>
resolveEntitiesToPointers(
engine,
req.sourceId || defaultSource,
req.candidates ?? [],
{
priorContextText: req.priorContextText,
maxPointers: req.maxPointers,
suppression: req.suppression,
},
),
// The IPC resolve path IS the ambient reflex channel. Logging happens
// at DELIVERY (post-write), not inside the resolver — a block the
// client's 250ms budget abandoned was never injected, and counting it
// would corrupt the volunteered-vs-used precision stats (red-team).
(block) => logDeliveredReflexPointers(engine, block.pointers),
);
}
} catch {
/* resolve IPC is best-effort; never block serve */
}
// Exit cleanly when MCP client disconnects (stdin EOF) or on signals.
// Without this, orphaned serve processes accumulate and contend for the
// PGLite write lock, causing ingest jobs (email-sync) to time out.
let shuttingDown = false;
const shutdown = (reason: string, code = 0) => {
if (shuttingDown) return;
shuttingDown = true;
process.stderr.write(`[gbrain-serve] shutdown: ${reason}\n`);
try { resolveServer?.close(); } catch { /* noop */ }
if (resolveSocket) cleanupStaleSocket(resolveSocket);
Promise.resolve(engine.disconnect?.())
.catch(() => {})
.finally(() => process.exit(code));
};
// v0.34.1 (#870): when MCP_STDIO=1, the wrapping gateway (OpenClaw's
// bundle-mcp layer, others) often pipes the JSON-RPC handshake then
// closes its stdin half. Treating that as a permanent disconnect kills
// the server before the first tool call arrives. Signal handlers and
// transport.onclose still cover the legitimate shutdown paths.
if (process.env.MCP_STDIO !== '1') {
process.stdin.on('end', () => shutdown('stdin end'));
process.stdin.on('close', () => shutdown('stdin close'));
}
// @ts-ignore — SDK exposes onclose on transport
transport.onclose = () => shutdown('transport close');
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGHUP', () => shutdown('SIGHUP'));
}
// Backward compat: used by `gbrain call` command (trusted local path).
// v0.31.8 (D22): accept opts.sourceId so `gbrain call --source X <op> <json>`
// can scope the op handler to that source. resolveSourceId() in call.ts is
// the upstream resolver; this layer just passes the resolved id through.
export async function handleToolCall(
engine: BrainEngine,
tool: string,
params: Record<string, unknown>,
opts?: { sourceId?: string },
): Promise<unknown> {
const op = operations.find(o => o.name === tool);
if (!op) throw new Error(`Unknown tool: ${tool}`);
const validationError = validateParams(op, params);
if (validationError) throw new Error(validationError);
const ctx = buildOperationContext(engine, params, {
remote: false,
logger: { info: console.log, warn: console.warn, error: console.error },
...(opts?.sourceId ? { sourceId: opts.sourceId } : {}),
});
return op.handler(ctx, params);
}