mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Claude Opus 4.6
parent
612f0f8396
commit
8f063ce10c
@@ -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=
|
||||
@@ -3,6 +3,7 @@ bin/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env.testing
|
||||
.env.production
|
||||
.18a49dfd730ff378-00000000.bun-build
|
||||
.18a49f9dfb996f70-00000000.bun-build
|
||||
.gstack/
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+67
@@ -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."
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
-- ============================================================
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
-- ============================================================
|
||||
|
||||
@@ -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
@@ -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