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>
This commit is contained in:
Garry Tan
2026-04-10 07:46:16 -10:00
co-authored by Claude Opus 4.6
parent 612f0f8396
commit 8f063ce10c
12 changed files with 1239 additions and 8 deletions
+12
View File
@@ -0,0 +1,12 @@
# GBrain Remote MCP Server — Production Config
# Copy to .env.production and fill in your values.
# Supabase pooler URL (Settings > Database > Connection string > Transaction pooler)
# Use the transaction pooler (port 6543), NOT the direct connection.
DATABASE_URL=postgresql://postgres.xxx:password@aws-0-us-west-1.pooler.supabase.com:6543/postgres
# OpenAI API key for embeddings
OPENAI_API_KEY=sk-...
# Supabase project ref (the "xxx" from https://xxx.supabase.co)
SUPABASE_PROJECT_REF=
+1
View File
@@ -3,6 +3,7 @@ bin/
.DS_Store
*.log
.env.testing
.env.production
.18a49dfd730ff378-00000000.bun-build
.18a49f9dfb996f70-00000000.bun-build
.gstack/
+1
View File
@@ -18,6 +18,7 @@
"build": "bun build --compile --outfile bin/gbrain src/cli.ts",
"build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts",
"build:schema": "bash scripts/build-schema.sh",
"build:edge": "bun run build:schema && bun build src/edge-entry.ts --format=esm --outfile=supabase/functions/gbrain-mcp/gbrain-core.js --external=postgres --external=openai --external=fs --external=os --external=path --external=crypto --external=child_process --external=@aws-sdk/client-s3 --external=@anthropic-ai/sdk --external=gray-matter --minify",
"test": "bun test",
"test:e2e": "bun test test/e2e/",
"prepublish:clawhub": "bun run build:all",
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# Deploy GBrain Remote MCP Server to Supabase Edge Functions.
# Prerequisites: .env.production filled in, supabase CLI installed.
set -e
# Check supabase CLI
if ! command -v supabase >/dev/null 2>&1; then
echo "Error: supabase CLI not found."
echo "Install: brew install supabase/tap/supabase"
echo " or: npm install -g supabase"
exit 1
fi
# Load env
if [ ! -f .env.production ]; then
echo "Error: .env.production not found."
echo "Copy .env.production.example to .env.production and fill in your values."
exit 1
fi
source .env.production
if [ -z "$SUPABASE_PROJECT_REF" ]; then
echo "Error: SUPABASE_PROJECT_REF not set in .env.production"
exit 1
fi
echo "Deploying GBrain Remote MCP Server..."
echo " Project: $SUPABASE_PROJECT_REF"
echo ""
# Link project
supabase link --project-ref "$SUPABASE_PROJECT_REF"
# Set secrets
supabase secrets set OPENAI_API_KEY="$OPENAI_API_KEY"
# Build the Edge Function bundle
echo ""
echo "Building Edge Function bundle..."
bun install
bun run build:edge
echo ""
# Deploy
echo "Deploying Edge Function..."
supabase functions deploy gbrain-mcp --no-verify-jwt
echo ""
# Print success
URL="https://${SUPABASE_PROJECT_REF}.supabase.co/functions/v1/gbrain-mcp/mcp"
echo "================================================"
echo " GBrain Remote MCP Server deployed!"
echo "================================================"
echo ""
echo " URL: $URL"
echo ""
echo " Next steps:"
echo " 1. Create a token:"
echo " DATABASE_URL=\$DATABASE_URL bun run src/commands/auth.ts create \"my-client\""
echo ""
echo " 2. Test it:"
echo " bun run src/commands/auth.ts test $URL --token <your-token>"
echo ""
echo " 3. Add to Claude Code:"
echo " claude mcp add gbrain -t http $URL -H \"Authorization: Bearer <token>\""
echo ""
echo " See docs/mcp/ for per-client setup guides."
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env bun
/**
* GBrain token management — standalone script, no gbrain CLI dependency.
*
* Usage:
* DATABASE_URL=... bun run src/commands/auth.ts create "claude-desktop"
* DATABASE_URL=... bun run src/commands/auth.ts list
* DATABASE_URL=... bun run src/commands/auth.ts revoke "claude-desktop"
* DATABASE_URL=... bun run src/commands/auth.ts test <url> --token <token>
*/
import postgres from 'postgres';
import { createHash, randomBytes } from 'crypto';
const DATABASE_URL = process.env.DATABASE_URL || process.env.GBRAIN_DATABASE_URL;
if (!DATABASE_URL && process.argv[2] !== 'test') {
console.error('Set DATABASE_URL or GBRAIN_DATABASE_URL environment variable.');
process.exit(1);
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function generateToken(): string {
return 'gbrain_' + randomBytes(32).toString('hex');
}
async function create(name: string) {
if (!name) { console.error('Usage: auth create <name>'); process.exit(1); }
const sql = postgres(DATABASE_URL!);
const token = generateToken();
const hash = hashToken(token);
try {
await sql`
INSERT INTO access_tokens (name, token_hash)
VALUES (${name}, ${hash})
`;
console.log(`Token created for "${name}":\n`);
console.log(` ${token}\n`);
console.log('Save this token — it will not be shown again.');
console.log(`Revoke with: bun run src/commands/auth.ts revoke "${name}"`);
} catch (e: any) {
if (e.code === '23505') {
console.error(`A token named "${name}" already exists. Revoke it first or use a different name.`);
} else {
console.error('Error:', e.message);
}
process.exit(1);
} finally {
await sql.end();
}
}
async function list() {
const sql = postgres(DATABASE_URL!);
try {
const rows = await sql`
SELECT name, created_at, last_used_at, revoked_at
FROM access_tokens
ORDER BY created_at DESC
`;
if (rows.length === 0) {
console.log('No tokens found. Create one: bun run src/commands/auth.ts create "my-client"');
return;
}
console.log('Name Created Last Used Status');
console.log('─'.repeat(80));
for (const r of rows) {
const name = (r.name as string).padEnd(20);
const created = new Date(r.created_at as string).toISOString().slice(0, 19);
const lastUsed = r.last_used_at ? new Date(r.last_used_at as string).toISOString().slice(0, 19) : 'never'.padEnd(19);
const status = r.revoked_at ? 'REVOKED' : 'active';
console.log(`${name} ${created} ${lastUsed} ${status}`);
}
} finally {
await sql.end();
}
}
async function revoke(name: string) {
if (!name) { console.error('Usage: auth revoke <name>'); process.exit(1); }
const sql = postgres(DATABASE_URL!);
try {
const result = await sql`
UPDATE access_tokens SET revoked_at = now()
WHERE name = ${name} AND revoked_at IS NULL
`;
if (result.count === 0) {
console.error(`No active token found with name "${name}".`);
process.exit(1);
}
console.log(`Token "${name}" revoked.`);
} finally {
await sql.end();
}
}
async function test(url: string, token: string) {
if (!url || !token) {
console.error('Usage: auth test <url> --token <token>');
process.exit(1);
}
const startTime = Date.now();
console.log(`Testing MCP server at ${url}...\n`);
// Step 1: Initialize
try {
const initRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'gbrain-smoke-test', version: '1.0' },
},
id: 1,
}),
});
if (!initRes.ok) {
console.error(` Initialize failed: ${initRes.status} ${initRes.statusText}`);
const body = await initRes.text();
if (body) console.error(` ${body}`);
process.exit(1);
}
console.log(' ✓ Initialize handshake');
} catch (e: any) {
console.error(` ✗ Connection failed: ${e.message}`);
process.exit(1);
}
// Step 2: List tools
try {
const listRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: 2,
}),
});
if (!listRes.ok) {
console.error(` ✗ tools/list failed: ${listRes.status}`);
process.exit(1);
}
const text = await listRes.text();
// Parse SSE or JSON response
let toolCount = 0;
if (text.includes('event:')) {
// SSE format: extract data lines
const dataLines = text.split('\n').filter(l => l.startsWith('data:'));
for (const line of dataLines) {
try {
const data = JSON.parse(line.slice(5));
if (data.result?.tools) toolCount = data.result.tools.length;
} catch { /* skip non-JSON lines */ }
}
} else {
try {
const data = JSON.parse(text);
toolCount = data.result?.tools?.length || 0;
} catch { /* parse error */ }
}
console.log(` ✓ tools/list: ${toolCount} tools available`);
} catch (e: any) {
console.error(` ✗ tools/list failed: ${e.message}`);
process.exit(1);
}
// Step 3: Call get_stats (real tool call)
try {
const statsRes = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'get_stats', arguments: {} },
id: 3,
}),
});
if (!statsRes.ok) {
console.error(` ✗ get_stats failed: ${statsRes.status}`);
process.exit(1);
}
console.log(' ✓ get_stats: brain is responding');
} catch (e: any) {
console.error(` ✗ get_stats failed: ${e.message}`);
process.exit(1);
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\n🧠 Your brain is live! (${elapsed}s)`);
}
// CLI dispatch
const [cmd, ...args] = process.argv.slice(2);
switch (cmd) {
case 'create': await create(args[0]); break;
case 'list': await list(); break;
case 'revoke': await revoke(args[0]); break;
case 'test': {
const tokenIdx = args.indexOf('--token');
const url = args.find(a => !a.startsWith('--') && a !== args[tokenIdx + 1]);
const token = tokenIdx >= 0 ? args[tokenIdx + 1] : '';
await test(url || '', token || '');
break;
}
default:
console.log(`GBrain Token Management
Usage:
bun run src/commands/auth.ts create <name> Create a new access token
bun run src/commands/auth.ts list List all tokens
bun run src/commands/auth.ts revoke <name> Revoke a token
bun run src/commands/auth.ts test <url> --token <token> Smoke test a remote MCP server
`);
}
+9 -8
View File
@@ -3,8 +3,9 @@ import { join } from 'path';
import { homedir } from 'os';
import type { EngineConfig } from './types.ts';
const CONFIG_DIR = join(homedir(), '.gbrain');
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
// Lazy-evaluated to avoid calling homedir() at module scope (breaks in Deno Edge Functions)
function getConfigDir() { return join(homedir(), '.gbrain'); }
function getConfigPath() { return join(getConfigDir(), 'config.json'); }
export interface GBrainConfig {
engine: 'postgres' | 'sqlite';
@@ -21,7 +22,7 @@ export interface GBrainConfig {
export function loadConfig(): GBrainConfig | null {
let fileConfig: GBrainConfig | null = null;
try {
const raw = readFileSync(CONFIG_PATH, 'utf-8');
const raw = readFileSync(getConfigPath(), 'utf-8');
fileConfig = JSON.parse(raw) as GBrainConfig;
} catch { /* no config file */ }
@@ -40,10 +41,10 @@ export function loadConfig(): GBrainConfig | null {
}
export function saveConfig(config: GBrainConfig): void {
mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
mkdirSync(getConfigDir(), { recursive: true });
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
try {
chmodSync(CONFIG_PATH, 0o600);
chmodSync(getConfigPath(), 0o600);
} catch {
// chmod may fail on some platforms
}
@@ -58,9 +59,9 @@ export function toEngineConfig(config: GBrainConfig): EngineConfig {
}
export function getConfigDir(): string {
return CONFIG_DIR;
return getConfigDir();
}
export function getConfigPath(): string {
return CONFIG_PATH;
return getConfigPath();
}
+27
View File
@@ -146,6 +146,33 @@ INSERT INTO config (key, value) VALUES
('chunk_strategy', 'semantic')
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- access_tokens: bearer tokens for remote MCP access
-- ============================================================
CREATE TABLE IF NOT EXISTS access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes TEXT[],
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash) WHERE revoked_at IS NULL;
-- ============================================================
-- mcp_request_log: usage logging for remote MCP requests
-- ============================================================
CREATE TABLE IF NOT EXISTS mcp_request_log (
id SERIAL PRIMARY KEY,
token_name TEXT,
operation TEXT NOT NULL,
latency_ms INTEGER,
status TEXT NOT NULL DEFAULT 'success',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- files: binary attachments stored in Supabase Storage
-- ============================================================
+16
View File
@@ -0,0 +1,16 @@
/**
* Edge Function bundle entry point.
*
* Curated exports for Supabase Edge Functions (Deno runtime).
* Excludes modules that depend on Node.js filesystem APIs:
* - db.ts (reads schema.sql from disk — now uses schema-embedded.ts)
* - config.ts (reads ~/.gbrain/config.json via homedir())
* - import-file.ts (uses readFileSync/statSync)
* - sync.ts (git-based, local filesystem)
*/
export { operations, operationsByName, OperationError } from './core/operations.ts';
export type { Operation, OperationContext, ParamDef } from './core/operations.ts';
export { PostgresEngine } from './core/postgres-engine.ts';
export type { BrainEngine } from './core/engine.ts';
export * from './core/types.ts';
export { VERSION } from './version.ts';
+27
View File
@@ -142,6 +142,33 @@ INSERT INTO config (key, value) VALUES
('chunk_strategy', 'semantic')
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- access_tokens: bearer tokens for remote MCP access
-- ============================================================
CREATE TABLE IF NOT EXISTS access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes TEXT[],
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash) WHERE revoked_at IS NULL;
-- ============================================================
-- mcp_request_log: usage logging for remote MCP requests
-- ============================================================
CREATE TABLE IF NOT EXISTS mcp_request_log (
id SERIAL PRIMARY KEY,
token_name TEXT,
operation TEXT NOT NULL,
latency_ms INTEGER,
status TEXT NOT NULL DEFAULT 'success',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- files: binary attachments stored in Supabase Storage
-- ============================================================
+10
View 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"
}
}
File diff suppressed because one or more lines are too long
+288
View 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);