feat: GBrain v0.6.0 — Remote MCP Server + 12 Bug Fixes (#28)
* fix: 7 bug fixes from Issue #9 and #22 - fix(mcp): use ListToolsRequestSchema/CallToolRequestSchema instead of string literals (Issue #9, PR #25) - fix(mcp): handleToolCall reads dry_run from params instead of hardcoding false (#22 Bug #11) - fix(search): keyword search returns best chunk per page via DISTINCT ON, not all chunks (#22 Bug #8) - fix(search): dedup layer 1 keeps top 3 chunks per page instead of collapsing to 1 (#22 Bug #12) - fix(engine): transaction uses scoped engine via Object.create, no shared state mutation (#22 Bug #2) - fix(engine): upsertChunks uses UPSERT instead of DELETE+INSERT, preserves existing embeddings (#22 Bug #1) - fix(slugs): validateSlug normalizes to lowercase, pathToSlug lowercases consistently (#22 Bug #4) - schema: add unique index on content_chunks(page_id, chunk_index) for UPSERT support - schema: add access_tokens and mcp_request_log tables via migration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: embed schema.sql at build time, remove fs dependency from initSchema initSchema() previously read schema.sql from disk at runtime via readFileSync, which broke in compiled Bun binaries and Deno Edge Functions. Now uses a generated schema-embedded.ts constant (run `bun run build:schema` to regenerate). - Removes fs and path imports from postgres-engine.ts and db.ts - Adds scripts/build-schema.sh for one-source-of-truth generation - Adds build:schema npm script Fixes Issue #22 Bug #6. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: 5 more bug fixes from Issue #22 - fix(file_upload): call storage.upload() in all 3 paths (operation, CLI upload, CLI sync) with rollback semantics (#22 Bug #9) - fix(import): use atomic index counter for parallel queue instead of array.shift() race, preserve checkpoint on errors (#22 Bug #3) - fix(s3): replace unsigned fetch with @aws-sdk/client-s3 for proper SigV4 auth, supports R2/MinIO via forcePathStyle (#22 Bug #10) - fix(redirect): verify remote file exists before deleting local copy, skip files not found in storage (#22 Bug #5) - deps: add @aws-sdk/client-s3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: remote MCP server via Supabase Edge Functions Deploy GBrain as a serverless remote MCP endpoint on your existing Supabase instance. One brain, accessible from Claude Desktop, Claude Code, Cowork, Perplexity Computer, and any MCP client. Zero new infrastructure. New files: - supabase/functions/gbrain-mcp/index.ts — Edge Function with Hono + MCP SDK - supabase/functions/gbrain-mcp/deno.json — Deno import map - src/edge-entry.ts — curated bundle entry point (excludes fs-dependent modules) - src/commands/auth.ts — standalone token management (create/list/revoke/test) - scripts/deploy-remote.sh — one-script deployment - .env.production.example — 3-value config template Changes: - config.ts: lazy-evaluate CONFIG_DIR (no homedir() at module scope) - schema.sql: add access_tokens + mcp_request_log tables - package.json: add build:edge script Auth: bearer tokens via access_tokens table (SHA-256 hashed, per-client, revocable) Transport: WebStandardStreamableHTTPServerTransport (stateless, Streamable HTTP) Health: /health endpoint (unauth: 200/503, auth: postgres/pgvector/openai checks) Excluded from remote: sync_brain, file_upload (may exceed 60s timeout) Setup: clone, fill .env.production, run scripts/deploy-remote.sh, create token, done. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: per-client MCP setup guides - docs/mcp/DEPLOY.md — deployment walkthrough, auth, troubleshooting, latency table - docs/mcp/CLAUDE_CODE.md — claude mcp add command - docs/mcp/CLAUDE_DESKTOP.md — Settings > Integrations (NOT JSON config!) - docs/mcp/CLAUDE_COWORK.md — remote + local bridge paths - docs/mcp/PERPLEXITY.md — Perplexity Computer connector setup - docs/mcp/CHATGPT.md — coming soon (requires OAuth 2.1, P0 TODO) - docs/mcp/ALTERNATIVES.md — Tailscale Funnel + ngrok self-hosted options Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.6.0) GBrain v0.6.0: Remote MCP server via Supabase Edge Functions + 12 bug fixes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add Remote MCP Server section to README Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: make document-release mandatory in CLAUDE.md, add MCP key files Post-ship requirements section: document-release is NOT optional. Lists every file that must be checked on every ship. A ship without updated docs is incomplete. Also adds remote MCP server files to Key files section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: batch upsertChunks into single statement to prevent deadlocks The per-chunk UPSERT loop caused deadlocks under parallel workers because each INSERT ON CONFLICT acquired row-level locks sequentially. Multiple workers upserting different pages could deadlock on the shared unique index. Fix: batch all chunks into a single multi-row INSERT ON CONFLICT statement. One round-trip, one lock acquisition. COALESCE preserves existing embeddings when the new value is NULL. Fixes CI failure: "E2E: Parallel Import > parallel import with --workers 4" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: advisory lock in initSchema() prevents deadlock on concurrent DDL When multiple processes call initSchema() concurrently (e.g., test setup + CLI subprocess, or parallel workers during E2E tests), the schema SQL's DROP TRIGGER + CREATE TRIGGER statements acquire AccessExclusiveLock on different tables, causing deadlocks. Fix: pg_advisory_lock(42) serializes all initSchema() calls within the same database. The lock is session-scoped and released in a finally block. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add explicit test timeouts for CLI subprocess E2E tests CLI subprocess tests (Setup Journey, Doctor Command, Parallel Import) spawn `bun run src/cli.ts` which takes several seconds to JIT compile + connect. The Bun test framework default 5000ms per-test timeout is too tight for CI. Added 30-60s timeouts matching each subprocess's own timeout to prevent false failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: infinite recursion in config.ts exported getConfigDir/getConfigPath The replace_all refactor created recursive functions: the exported getConfigDir() called the private getConfigDir() which called itself. Renamed exports to configDir()/configPath() to avoid shadowing. Also adds scripts/smoke-test-mcp.ts — verified all 8 MCP tool calls work against a real Postgres database. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
supabase/functions/gbrain-mcp/deno.json
Normal file
10
supabase/functions/gbrain-mcp/deno.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"imports": {
|
||||
"postgres": "npm:postgres@3",
|
||||
"openai": "npm:openai@4",
|
||||
"@modelcontextprotocol/sdk/": "npm:@modelcontextprotocol/sdk@1/",
|
||||
"hono": "npm:hono@4",
|
||||
"hono/cors": "npm:hono@4/cors",
|
||||
"crypto": "node:crypto"
|
||||
}
|
||||
}
|
||||
540
supabase/functions/gbrain-mcp/gbrain-core.js
Normal file
540
supabase/functions/gbrain-mcp/gbrain-core.js
Normal file
File diff suppressed because one or more lines are too long
288
supabase/functions/gbrain-mcp/index.ts
Normal file
288
supabase/functions/gbrain-mcp/index.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* GBrain Remote MCP Server — Supabase Edge Function
|
||||
*
|
||||
* Exposes GBrain operations as remote MCP tools via Streamable HTTP transport.
|
||||
* Auth via bearer tokens stored in access_tokens table (SHA-256 hashed).
|
||||
*
|
||||
* Deploy: supabase functions deploy gbrain-mcp --no-verify-jwt
|
||||
* URL: https://<project>.supabase.co/functions/v1/gbrain-mcp/mcp
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
||||
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import postgres from 'postgres';
|
||||
import { createHash } from 'crypto';
|
||||
import { operations, OperationError, PostgresEngine, VERSION } from './gbrain-core.js';
|
||||
import type { OperationContext } from './gbrain-core.js';
|
||||
|
||||
// Operations excluded from remote (may exceed 60s Edge Function timeout)
|
||||
const REMOTE_EXCLUDED = new Set(['sync_brain', 'file_upload']);
|
||||
const remoteOps = operations.filter((op: any) => !REMOTE_EXCLUDED.has(op.name));
|
||||
|
||||
// Database connection (lazy, one per isolate)
|
||||
let engine: PostgresEngine | null = null;
|
||||
let sql: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
function getDbUrl(): string {
|
||||
// @ts-ignore: Deno env
|
||||
return Deno.env.get('SUPABASE_DB_URL') || Deno.env.get('DATABASE_URL') || '';
|
||||
}
|
||||
|
||||
function getOpenAiKey(): string {
|
||||
// @ts-ignore: Deno env
|
||||
return Deno.env.get('OPENAI_API_KEY') || '';
|
||||
}
|
||||
|
||||
async function getEngine(): Promise<PostgresEngine> {
|
||||
if (!engine) {
|
||||
engine = new PostgresEngine();
|
||||
await engine.connect({ database_url: getDbUrl(), poolSize: 1 });
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
function getDirectSql(): ReturnType<typeof postgres> {
|
||||
if (!sql) {
|
||||
sql = postgres(getDbUrl(), { max: 1 });
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
|
||||
// Auth: check bearer token against access_tokens table
|
||||
async function authenticateToken(authHeader: string | null): Promise<{ valid: boolean; name?: string; error?: string; status?: number }> {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: JSON.stringify({
|
||||
error: 'missing_auth',
|
||||
message: "Authorization header required. Use 'Bearer <token>' format.",
|
||||
docs: 'docs/mcp/DEPLOY.md#authentication',
|
||||
}),
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const hash = createHash('sha256').update(token).digest('hex');
|
||||
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
const rows = await conn`
|
||||
SELECT name, revoked_at FROM access_tokens
|
||||
WHERE token_hash = ${hash}
|
||||
`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: JSON.stringify({
|
||||
error: 'invalid_token',
|
||||
message: "Token not recognized. Run 'bun run src/commands/auth.ts list' to see active tokens.",
|
||||
docs: 'docs/mcp/DEPLOY.md#troubleshooting',
|
||||
}),
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
if (rows[0].revoked_at) {
|
||||
const revokedDate = new Date(rows[0].revoked_at as string).toISOString().slice(0, 10);
|
||||
return {
|
||||
valid: false,
|
||||
error: JSON.stringify({
|
||||
error: 'token_revoked',
|
||||
message: `This token was revoked on ${revokedDate}. Create a new one with 'bun run src/commands/auth.ts create <name>'.`,
|
||||
docs: 'docs/mcp/DEPLOY.md#token-management',
|
||||
}),
|
||||
status: 403,
|
||||
};
|
||||
}
|
||||
|
||||
// Update last_used_at
|
||||
const conn2 = getDirectSql();
|
||||
await conn2`UPDATE access_tokens SET last_used_at = now() WHERE token_hash = ${hash}`;
|
||||
|
||||
return { valid: true, name: rows[0].name as string };
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: JSON.stringify({
|
||||
error: 'service_unavailable',
|
||||
message: 'Database connection failed. Check Supabase dashboard for status.',
|
||||
docs: 'docs/mcp/DEPLOY.md#troubleshooting',
|
||||
}),
|
||||
status: 503,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Log MCP request for auditing
|
||||
async function logRequest(tokenName: string, operation: string, latencyMs: number, status: string) {
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
await conn`
|
||||
INSERT INTO mcp_request_log (token_name, operation, latency_ms, status)
|
||||
VALUES (${tokenName}, ${operation}, ${latencyMs}, ${status})
|
||||
`;
|
||||
} catch {
|
||||
// Best effort, don't crash on log failure
|
||||
console.error('[gbrain-mcp] Failed to log request');
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP Server with tool handlers
|
||||
function createMcpServer(eng: PostgresEngine): Server {
|
||||
const server = new Server(
|
||||
{ name: 'gbrain', version: VERSION },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: remoteOps.map((op: any) => ({
|
||||
name: op.name,
|
||||
description: op.description,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(op.params).map(([k, v]: [string, any]) => [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]: [string, any]) => v.required)
|
||||
.map(([k]: [string, any]) => k),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
|
||||
const { name, arguments: params } = request.params;
|
||||
const op = remoteOps.find((o: any) => o.name === name);
|
||||
if (!op) {
|
||||
return { content: [{ type: 'text', text: `Error: Unknown tool: ${name}` }], isError: true };
|
||||
}
|
||||
|
||||
const ctx: OperationContext = {
|
||||
engine: eng,
|
||||
config: {
|
||||
engine: 'postgres',
|
||||
database_url: getDbUrl(),
|
||||
openai_api_key: getOpenAiKey(),
|
||||
},
|
||||
logger: {
|
||||
info: (msg: string) => console.log(`[info] ${msg}`),
|
||||
warn: (msg: string) => console.warn(`[warn] ${msg}`),
|
||||
error: (msg: string) => console.error(`[error] ${msg}`),
|
||||
},
|
||||
dryRun: !!(params?.dry_run),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await op.handler(ctx, params || {});
|
||||
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 };
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// Hono app — routes: /mcp (MCP transport), /health (monitoring)
|
||||
const app = new Hono().basePath('/gbrain-mcp');
|
||||
|
||||
app.use('/*', cors({
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id', 'Mcp-Protocol-Version', 'Last-Event-ID'],
|
||||
exposeHeaders: ['Mcp-Session-Id'],
|
||||
}));
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
// Unauth: minimal response
|
||||
if (!authHeader) {
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
await conn`SELECT 1`;
|
||||
return c.json({ status: 'ok', version: VERSION });
|
||||
} catch {
|
||||
return c.json({ status: 'error' }, 503);
|
||||
}
|
||||
}
|
||||
|
||||
// Auth: detailed checks
|
||||
const auth = await authenticateToken(authHeader);
|
||||
if (!auth.valid) return c.json({ status: 'error' }, auth.status);
|
||||
|
||||
const checks: Record<string, string> = {};
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
await conn`SELECT 1`;
|
||||
checks.postgres = 'ok';
|
||||
} catch {
|
||||
checks.postgres = 'error';
|
||||
}
|
||||
|
||||
try {
|
||||
const conn = getDirectSql();
|
||||
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
|
||||
checks.pgvector = ext.length > 0 ? 'ok' : 'missing';
|
||||
} catch {
|
||||
checks.pgvector = 'error';
|
||||
}
|
||||
|
||||
checks.openai = getOpenAiKey() ? 'configured' : 'missing';
|
||||
|
||||
const status = Object.values(checks).every(v => v === 'ok' || v === 'configured') ? 'ok' : 'degraded';
|
||||
return c.json({ status, version: VERSION, checks });
|
||||
});
|
||||
|
||||
// MCP endpoint
|
||||
app.all('/mcp', async (c) => {
|
||||
// Auth check
|
||||
const auth = await authenticateToken(c.req.header('Authorization') || null);
|
||||
if (!auth.valid) {
|
||||
return new Response(auth.error, {
|
||||
status: auth.status || 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const eng = await getEngine();
|
||||
const server = createMcpServer(eng);
|
||||
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
// Stateless mode — no sessions needed for single-user personal brain
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
try {
|
||||
const response = await transport.handleRequest(c.req.raw);
|
||||
|
||||
// Log the request (await to ensure it completes before isolate dies)
|
||||
const latency = Date.now() - startTime;
|
||||
await logRequest(auth.name || 'unknown', 'mcp_request', latency, 'success');
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
const latency = Date.now() - startTime;
|
||||
await logRequest(auth.name || 'unknown', 'mcp_request', latency, 'error');
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore: Deno.serve
|
||||
Deno.serve(app.fetch);
|
||||
Reference in New Issue
Block a user