mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
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>
168 lines
7.8 KiB
TypeScript
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);
|
|
}
|