mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +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>
314 lines
13 KiB
TypeScript
314 lines
13 KiB
TypeScript
/**
|
|
* Shared MCP tool-call dispatch — single source of truth for stdio + HTTP transports.
|
|
*
|
|
* Both transports validate the same params, build the same OperationContext shape,
|
|
* and serialize errors identically. Drift between transports caused PR #483's reversed-args
|
|
* + missing-context bugs; this module exists to prevent that recurring.
|
|
*/
|
|
|
|
import type { BrainEngine } from '../core/engine.ts';
|
|
import { operations, OperationError } from '../core/operations.ts';
|
|
import type { Operation, OperationContext, AuthInfo } from '../core/operations.ts';
|
|
import { loadConfig } from '../core/config.ts';
|
|
|
|
export interface ToolResult {
|
|
content: { type: 'text'; text: string }[];
|
|
isError?: boolean;
|
|
/**
|
|
* v0.31 (eD3): MCP spec-blessed metadata slot for server-supplied data.
|
|
* The dispatcher injects `_meta.brain_hot_memory` here when an op succeeds
|
|
* and the configured `metaHook` returns a payload.
|
|
*
|
|
* Existing clients ignore unknown `_meta` fields; capable clients (Claude
|
|
* Code, Claude Desktop) read it. NOT a wrapper around the result body —
|
|
* `content` stays the same shape it always had. Best-effort: any error in
|
|
* the meta hook is absorbed and the tool call still succeeds.
|
|
*/
|
|
_meta?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface DispatchOpts {
|
|
/** Defaults to true (remote/untrusted). Local CLI callers (`gbrain call`) pass false. */
|
|
remote?: boolean;
|
|
/** Override the default stderr logger (e.g. CLI uses console.* directly). */
|
|
logger?: OperationContext['logger'];
|
|
/**
|
|
* #1061: transport marker for auth-less remote surfaces. The stdio MCP
|
|
* server passes 'stdio' so identity ops (whoami) can report the transport
|
|
* instead of throwing unknown_transport. Never used for trust decisions.
|
|
*/
|
|
transport?: OperationContext['transport'];
|
|
/**
|
|
* v0.28: per-token allow-list for the takes.holder field. Threaded by
|
|
* the HTTP/stdio transport from `access_tokens.permissions.takes_holders`.
|
|
* When set, takes_list / takes_search / query (when it returns takes)
|
|
* MUST filter `WHERE holder = ANY($takesHoldersAllowList)`. Local CLI
|
|
* callers leave this unset (no filter — they own the brain).
|
|
*/
|
|
takesHoldersAllowList?: string[];
|
|
/**
|
|
* v0.31 (eD4): tenancy axis for facts hot memory ops (extract_facts,
|
|
* recall, forget_fact). When set, the OperationContext receives a
|
|
* matching `sourceId`. CLI dispatch resolves this from --source flag /
|
|
* GBRAIN_SOURCE / .gbrain-source / 'default'; HTTP MCP transport
|
|
* resolves it from the per-token allow-list (eE3).
|
|
*/
|
|
sourceId?: string;
|
|
/**
|
|
* #3242: federated read set for callers with NO explicit source scope
|
|
* (stdio without GBRAIN_SOURCE; legacy HTTP tokens without an operator-set
|
|
* `permissions.source_id` grant). Transport-computed, never derived from
|
|
* caller params. See OperationContext.localFederatedSourceIds.
|
|
*/
|
|
localFederatedSourceIds?: string[];
|
|
/**
|
|
* v0.31 (eD3): hook called by the dispatcher AFTER op.handler succeeds
|
|
* to compute `_meta.brain_hot_memory` for the response. Wrapped in its
|
|
* own try/catch (eE4) so a DB blip in the helper degrades to no _meta
|
|
* rather than flipping the whole tool call to error.
|
|
*
|
|
* Returning undefined means "no _meta to inject"; the dispatcher
|
|
* preserves the existing response shape.
|
|
*/
|
|
metaHook?: (
|
|
name: string,
|
|
ctx: OperationContext,
|
|
) => Promise<Record<string, unknown> | undefined>;
|
|
/**
|
|
* OAuth auth info threaded through from the HTTP MCP transport. Set so
|
|
* the whoami op (and any future scope-aware op handlers) can introspect
|
|
* the calling identity. Without this, every whoami call from HTTP
|
|
* transports throws unknown_transport — the v0.31 D12 / eE1 refactor
|
|
* silently dropped this field when the inlined OperationContext literal
|
|
* was replaced by dispatchToolCall.
|
|
*/
|
|
auth?: AuthInfo;
|
|
}
|
|
|
|
/**
|
|
* Build a privacy-safe summary of MCP request params for logging + the admin
|
|
* SSE feed.
|
|
*
|
|
* The previous default of `JSON.stringify(params)` wrote raw payloads —
|
|
* page bodies, search queries, file paths — into `mcp_request_log` and
|
|
* broadcast them to every connected admin browser. For a personal-knowledge
|
|
* brain those payloads include private notes about real people / deals /
|
|
* companies, retained indefinitely.
|
|
*
|
|
* The redactor returns the SHAPE of the request (what op was called, which
|
|
* declared params were passed, approximate size) without any of the values.
|
|
*
|
|
* Hardening note (codex C8): a naive "dump all submitted keys" summary still
|
|
* leaks via attacker-controlled key names — a caller can submit
|
|
* `put_page {"wiki/people/sensitive_name": "..."}` and the key becomes a
|
|
* persistent log entry. To prevent this, we intersect submitted keys
|
|
* against the operation's declared `params` allow-list (the same definition
|
|
* `validateParams` reads). Anything outside the allow-list is counted but
|
|
* not named.
|
|
*
|
|
* Operators who want full payloads for debugging set `--log-full-params` on
|
|
* `gbrain serve --http`; that path bypasses this helper and writes the raw
|
|
* JSON, with a loud startup warning.
|
|
*/
|
|
export interface ParamSummary {
|
|
redacted: true;
|
|
kind: 'array' | 'object' | string;
|
|
declared_keys?: string[];
|
|
unknown_key_count?: number;
|
|
length?: number;
|
|
approx_bytes?: number;
|
|
}
|
|
|
|
/**
|
|
* Round a byte count UP to the nearest 1KB so the redacted summary keeps a
|
|
* coarse size signal without enabling a size-based side channel.
|
|
*
|
|
* Why bucketing matters: the previous shape published `approx_bytes` as the
|
|
* exact JSON.stringify(params).length. An attacker who can submit
|
|
* `put_page` with a known prefix and observe the resulting log entry
|
|
* could binary-search the byte length of secret content (the body the
|
|
* legitimate user just wrote) via repeated probes. Bucketing to 1KB
|
|
* resolution destroys that channel while preserving the operator-useful
|
|
* "roughly how large was the request" signal.
|
|
*/
|
|
function bucketBytes(n: number | undefined): number | undefined {
|
|
if (n === undefined || !Number.isFinite(n)) return undefined;
|
|
if (n <= 0) return 0;
|
|
const KB = 1024;
|
|
return Math.ceil(n / KB) * KB;
|
|
}
|
|
|
|
export function summarizeMcpParams(opName: string, params: unknown): ParamSummary | null {
|
|
if (params == null) return null;
|
|
|
|
let approxBytes: number | undefined;
|
|
try { approxBytes = bucketBytes(JSON.stringify(params).length); } catch { approxBytes = undefined; }
|
|
|
|
if (Array.isArray(params)) {
|
|
return {
|
|
redacted: true,
|
|
kind: 'array',
|
|
length: params.length,
|
|
...(approxBytes !== undefined ? { approx_bytes: approxBytes } : {}),
|
|
};
|
|
}
|
|
|
|
if (typeof params === 'object') {
|
|
const submittedKeys = Object.keys(params as Record<string, unknown>);
|
|
const op = operations.find(o => o.name === opName);
|
|
const allowList = op ? new Set(Object.keys(op.params)) : new Set<string>();
|
|
const declared: string[] = [];
|
|
let unknown = 0;
|
|
for (const k of submittedKeys) {
|
|
if (allowList.has(k)) declared.push(k);
|
|
else unknown += 1;
|
|
}
|
|
declared.sort();
|
|
return {
|
|
redacted: true,
|
|
kind: 'object',
|
|
declared_keys: declared,
|
|
unknown_key_count: unknown,
|
|
...(approxBytes !== undefined ? { approx_bytes: approxBytes } : {}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
redacted: true,
|
|
kind: typeof params,
|
|
...(approxBytes !== undefined ? { approx_bytes: approxBytes } : {}),
|
|
};
|
|
}
|
|
|
|
/** Validate required params exist and have the expected type. Returns null on success, error message on failure. */
|
|
export function validateParams(op: Operation, params: Record<string, unknown>): string | null {
|
|
for (const [key, def] of Object.entries(op.params)) {
|
|
if (def.required && (params[key] === undefined || params[key] === null)) {
|
|
return `Missing required parameter: ${key}`;
|
|
}
|
|
if (params[key] !== undefined && params[key] !== null) {
|
|
const val = params[key];
|
|
const expected = def.type;
|
|
if (expected === 'string' && typeof val !== 'string') return `Parameter "${key}" must be a string`;
|
|
if (expected === 'number' && typeof val !== 'number') return `Parameter "${key}" must be a number`;
|
|
if (expected === 'boolean' && typeof val !== 'boolean') return `Parameter "${key}" must be a boolean`;
|
|
if (expected === 'object' && (typeof val !== 'object' || Array.isArray(val))) return `Parameter "${key}" must be an object`;
|
|
if (expected === 'array' && !Array.isArray(val)) return `Parameter "${key}" must be an array`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const stderrLogger: OperationContext['logger'] = {
|
|
info: (msg: string) => process.stderr.write(`[info] ${msg}\n`),
|
|
warn: (msg: string) => process.stderr.write(`[warn] ${msg}\n`),
|
|
error: (msg: string) => process.stderr.write(`[error] ${msg}\n`),
|
|
};
|
|
|
|
export function buildOperationContext(
|
|
engine: BrainEngine,
|
|
params: Record<string, unknown>,
|
|
opts: DispatchOpts = {},
|
|
): OperationContext {
|
|
return {
|
|
engine,
|
|
config: loadConfig() || { engine: 'postgres' },
|
|
logger: opts.logger || stderrLogger,
|
|
dryRun: !!params.dry_run,
|
|
remote: opts.remote ?? true,
|
|
transport: opts.transport,
|
|
takesHoldersAllowList: opts.takesHoldersAllowList,
|
|
// v0.34 D4: sourceId is REQUIRED at the type level. Auto-fill 'default'
|
|
// for single-source brains and any caller who didn't resolve a sourceId.
|
|
// CLI / HTTP / stdio transports SHOULD pass an explicit sourceId via opts;
|
|
// this fallback covers code paths that historically passed undefined.
|
|
sourceId: opts.sourceId ?? 'default',
|
|
...(opts.localFederatedSourceIds ? { localFederatedSourceIds: opts.localFederatedSourceIds } : {}),
|
|
auth: opts.auth,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve operation, validate params, build context, invoke handler, format result.
|
|
*
|
|
* Returns a `ToolResult` with the same shape both MCP transports need:
|
|
* `{ content: [{ type: 'text', text }], isError?: boolean }`.
|
|
*/
|
|
export async function dispatchToolCall(
|
|
engine: BrainEngine,
|
|
name: string,
|
|
params: Record<string, unknown> | undefined,
|
|
opts: DispatchOpts = {},
|
|
): Promise<ToolResult> {
|
|
const op = operations.find(o => o.name === name);
|
|
if (!op) {
|
|
// Always return JSON-shaped error content. v0.31 e2e tests
|
|
// (sources-remote-mcp.test.ts) parse content via JSON.parse so a
|
|
// plain `Error: ...` string here breaks the contract on every
|
|
// unknown-op path and the resulting test failure looked like a
|
|
// transport bug.
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ error: 'unknown_tool', message: `Unknown tool: ${name}` }, null, 2) }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const safeParams = params || {};
|
|
const validationError = validateParams(op, safeParams);
|
|
if (validationError) {
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ error: 'invalid_params', message: validationError }, null, 2) }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
// Remote callers must arrive with a resolved source scope. Every shipped
|
|
// transport passes sourceId explicitly (serve-http from the OAuth client
|
|
// row, http-transport from the legacy token grant, stdio from
|
|
// GBRAIN_SOURCE); a remote call reaching the 'default' fallback means a
|
|
// programmatic caller skipped scope resolution, and silently landing in
|
|
// the shared 'default' source is the cross-source leak class behind
|
|
// #1924 / #1371. Trusted local callers (remote === false) keep the
|
|
// historical fallback via buildOperationContext.
|
|
if ((opts.remote ?? true) && !opts.sourceId) {
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ error: 'missing_source_scope', message: `Remote tool call '${name}' carries no resolved sourceId; refusing the shared 'default' source fallback. Pass an explicit sourceId resolved from the caller's grant.` }, null, 2) }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const ctx = buildOperationContext(engine, safeParams, opts);
|
|
|
|
try {
|
|
const result = await op.handler(ctx, safeParams);
|
|
const out: ToolResult = { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
// v0.31 (eD3 + eE4): best-effort _meta.brain_hot_memory injection.
|
|
// The hook is wrapped in its own try/catch — any DB blip / cache miss /
|
|
// helper crash degrades to no `_meta` rather than flipping the whole
|
|
// tool call to error.
|
|
if (opts.metaHook) {
|
|
try {
|
|
const meta = await opts.metaHook(name, ctx);
|
|
if (meta && Object.keys(meta).length > 0) out._meta = meta;
|
|
} catch (metaErr) {
|
|
const msg = metaErr instanceof Error ? metaErr.message : String(metaErr);
|
|
ctx.logger.warn(`[mcp] _meta hook failed for ${name}: ${msg}; degrading to no-_meta`);
|
|
}
|
|
}
|
|
return out;
|
|
} catch (e: unknown) {
|
|
if (e instanceof OperationError) {
|
|
return { content: [{ type: 'text', text: JSON.stringify(e.toJSON(), null, 2) }], isError: true };
|
|
}
|
|
// Non-OperationError (uncaught throws) — wrap in the same shape so
|
|
// every error response is JSON-parseable. The pre-v0.31 path emitted
|
|
// plain `Error: ${msg}` strings here, which broke any caller that
|
|
// tried JSON.parse(content).
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify({ error: 'internal_error', message: msg }, null, 2) }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|