Files
gbrain/src/mcp/server.ts
Garry Tan 13773be071 fix: community fix wave — 10 PRs, 7 contributors (v0.9.1) (#65)
* fix: security hardening — search DoS, slug hijack, symlink traversal, content bombs, stdin guard

4 security vulnerabilities closed:
- Search limit clamped to 100 (MAX_SEARCH_LIMIT) with statement_timeout 8s
- Frontmatter slug authority enforced (path-derived, mismatch rejected)
- Symlink traversal blocked (lstatSync in walker + importFromFile)
- Content size guard on importFromContent (Buffer.byteLength, 5MB)
- Stdin size guard in parseOpArgs (5MB cap)

Search pagination added (--offset param on search + query operations).
Clamp warning emitted when limit is capped.

Co-Authored-By: garagon <garagon@users.noreply.github.com>

* fix: PGLite concurrent access lock — prevent Aborted() crash

File-based advisory lock using atomic mkdir with PID tracking
and 5-minute stale detection. Clear error messages show which
process holds the lock and how to recover.

Co-Authored-By: danbr <danbr@users.noreply.github.com>

* fix: 12 data integrity fixes + stale embedding prevention

CTE searchKeyword rewrite (SQL-level LIMIT, not JS splice).
Write validation on addLink/addTag/addTimelineEntry/putRawData/createVersion.
Health metrics now measure real problems (stale_pages, orphan_pages, dead_links).
Orphan chunk cleanup on empty pages. Embedding error logging.
contentHash now covers all PageInput fields.
Stale embedding NULL'd when chunk_text changes (prevents wrong vector on new text).
hybridSearch stops double-embedding query. MCP param validation.
type/exclude_slugs search filters now work. pgcrypto extension for Postgres <13.

Co-Authored-By: win4r <win4r@users.noreply.github.com>

* perf: 30x embedAll speedup + O(n²) fix + ask alias

Sliding worker pool (concurrency 20, tunable via GBRAIN_EMBED_CONCURRENCY).
O(n²) chunk lookup in embedPage replaced with Map.
gbrain ask alias for query (CLI-only, not in MCP tools-json).
.idea added to .gitignore.

Co-Authored-By: stephenhungg <stephenhungg@users.noreply.github.com>
Co-Authored-By: sharziki <sharziki@users.noreply.github.com>
Co-Authored-By: hnshah <hnshah@users.noreply.github.com>
Co-Authored-By: doguabaris <doguabaris@users.noreply.github.com>

* chore: bump version and changelog (v0.9.1)

Community fix wave: 10 PRs, 7 contributors.
4 security fixes, PGLite crash fix, 12 data integrity fixes,
30x embed speedup, search pagination, ask alias.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: garagon <garagon@users.noreply.github.com>
Co-authored-by: danbr <danbr@users.noreply.github.com>
Co-authored-by: win4r <win4r@users.noreply.github.com>
Co-authored-by: stephenhungg <stephenhungg@users.noreply.github.com>
Co-authored-by: sharziki <sharziki@users.noreply.github.com>
Co-authored-by: hnshah <hnshah@users.noreply.github.com>
Co-authored-by: doguabaris <doguabaris@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 07:48:47 -10:00

119 lines
4.7 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, OperationError } from '../core/operations.ts';
import type { Operation, OperationContext } from '../core/operations.ts';
import { loadConfig } from '../core/config.ts';
import { VERSION } from '../version.ts';
/** Validate required params exist and have the expected type */
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;
}
export async function startMcpServer(engine: BrainEngine) {
const server = new Server(
{ name: 'gbrain', version: VERSION },
{ capabilities: { tools: {} } },
);
// Generate tool definitions from operations
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: operations.map(op => ({
name: op.name,
description: op.description,
inputSchema: {
type: 'object' as const,
properties: Object.fromEntries(
Object.entries(op.params).map(([k, v]) => [k, {
type: v.type === 'array' ? 'array' : v.type,
...(v.description ? { description: v.description } : {}),
...(v.enum ? { enum: v.enum } : {}),
...(v.items ? { items: { type: v.items.type } } : {}),
}]),
),
required: Object.entries(op.params)
.filter(([, v]) => v.required)
.map(([k]) => k),
},
})),
}));
// Dispatch tool calls to operation handlers
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
const { name, arguments: params } = request.params;
const op = operations.find(o => o.name === name);
if (!op) {
return { content: [{ type: 'text', text: `Error: Unknown tool: ${name}` }], isError: true };
}
const ctx: OperationContext = {
engine,
config: loadConfig() || { engine: 'postgres' },
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`),
},
dryRun: !!(params?.dry_run),
};
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 };
}
try {
const result = await op.handler(ctx, safeParams);
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (e: unknown) {
if (e instanceof OperationError) {
return { content: [{ type: 'text', text: JSON.stringify(e.toJSON(), null, 2) }], isError: true };
}
const msg = e instanceof Error ? e.message : String(e);
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
}
// Backward compat: used by `gbrain call` command
export async function handleToolCall(
engine: BrainEngine,
tool: string,
params: Record<string, unknown>,
): 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: OperationContext = {
engine,
config: loadConfig() || { engine: 'postgres' },
logger: { info: console.log, warn: console.warn, error: console.error },
dryRun: !!(params?.dry_run),
};
return op.handler(ctx, params);
}