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:
12
.env.production.example
Normal file
12
.env.production.example
Normal 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
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ bin/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
.env.testing
|
.env.testing
|
||||||
|
.env.production
|
||||||
.18a49dfd730ff378-00000000.bun-build
|
.18a49dfd730ff378-00000000.bun-build
|
||||||
.18a49f9dfb996f70-00000000.bun-build
|
.18a49f9dfb996f70-00000000.bun-build
|
||||||
.gstack/
|
.gstack/
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"build": "bun build --compile --outfile bin/gbrain src/cli.ts",
|
"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: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: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": "bun test",
|
||||||
"test:e2e": "bun test test/e2e/",
|
"test:e2e": "bun test test/e2e/",
|
||||||
"prepublish:clawhub": "bun run build:all",
|
"prepublish:clawhub": "bun run build:all",
|
||||||
|
|||||||
67
scripts/deploy-remote.sh
Executable file
67
scripts/deploy-remote.sh
Executable 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
src/commands/auth.ts
Normal file
241
src/commands/auth.ts
Normal 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
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import { join } from 'path';
|
|||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import type { EngineConfig } from './types.ts';
|
import type { EngineConfig } from './types.ts';
|
||||||
|
|
||||||
const CONFIG_DIR = join(homedir(), '.gbrain');
|
// Lazy-evaluated to avoid calling homedir() at module scope (breaks in Deno Edge Functions)
|
||||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
function getConfigDir() { return join(homedir(), '.gbrain'); }
|
||||||
|
function getConfigPath() { return join(getConfigDir(), 'config.json'); }
|
||||||
|
|
||||||
export interface GBrainConfig {
|
export interface GBrainConfig {
|
||||||
engine: 'postgres' | 'sqlite';
|
engine: 'postgres' | 'sqlite';
|
||||||
@@ -21,7 +22,7 @@ export interface GBrainConfig {
|
|||||||
export function loadConfig(): GBrainConfig | null {
|
export function loadConfig(): GBrainConfig | null {
|
||||||
let fileConfig: GBrainConfig | null = null;
|
let fileConfig: GBrainConfig | null = null;
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
const raw = readFileSync(getConfigPath(), 'utf-8');
|
||||||
fileConfig = JSON.parse(raw) as GBrainConfig;
|
fileConfig = JSON.parse(raw) as GBrainConfig;
|
||||||
} catch { /* no config file */ }
|
} catch { /* no config file */ }
|
||||||
|
|
||||||
@@ -40,10 +41,10 @@ export function loadConfig(): GBrainConfig | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveConfig(config: GBrainConfig): void {
|
export function saveConfig(config: GBrainConfig): void {
|
||||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
mkdirSync(getConfigDir(), { recursive: true });
|
||||||
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
||||||
try {
|
try {
|
||||||
chmodSync(CONFIG_PATH, 0o600);
|
chmodSync(getConfigPath(), 0o600);
|
||||||
} catch {
|
} catch {
|
||||||
// chmod may fail on some platforms
|
// chmod may fail on some platforms
|
||||||
}
|
}
|
||||||
@@ -58,9 +59,9 @@ export function toEngineConfig(config: GBrainConfig): EngineConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigDir(): string {
|
export function getConfigDir(): string {
|
||||||
return CONFIG_DIR;
|
return getConfigDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigPath(): string {
|
export function getConfigPath(): string {
|
||||||
return CONFIG_PATH;
|
return getConfigPath();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,33 @@ INSERT INTO config (key, value) VALUES
|
|||||||
('chunk_strategy', 'semantic')
|
('chunk_strategy', 'semantic')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
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
|
-- files: binary attachments stored in Supabase Storage
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
16
src/edge-entry.ts
Normal file
16
src/edge-entry.ts
Normal 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';
|
||||||
@@ -142,6 +142,33 @@ INSERT INTO config (key, value) VALUES
|
|||||||
('chunk_strategy', 'semantic')
|
('chunk_strategy', 'semantic')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
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
|
-- files: binary attachments stored in Supabase Storage
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
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