* 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>
119 lines
4.7 KiB
TypeScript
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);
|
|
}
|