fix: add built-in HTTP transport with bearer auth for remote MCP

Adds `gbrain serve --http` with token-based authentication using the
existing access_tokens table. Eliminates the need for standalone OAuth
wrappers that may have insecure open registration endpoints.

- New: src/mcp/http-transport.ts — HTTP+SSE transport with bearer auth
- New: SECURITY.md — security advisory for remote MCP deployments
- Updated: serve command accepts --http and --port flags
- Updated: DEPLOY.md recommends --http for remote access
- Bump: 0.22.4 → 0.22.5
This commit is contained in:
root
2026-04-27 23:52:25 +00:00
parent 891c28b582
commit 6e740590b3
7 changed files with 270 additions and 5 deletions
+8
View File
@@ -1,6 +1,14 @@
# Changelog
All notable changes to GBrain will be documented in this file.
## [0.22.5] - 2026-04-27
### Security hotfix: HTTP transport for remote MCP
- **`gbrain serve --http`** — built-in HTTP transport with bearer token auth. Uses the existing `access_tokens` table. No OAuth, no registration endpoint. This is the recommended way to expose GBrain remotely.
- **SECURITY.md** — added security advisory and guidance for remote MCP deployments.
- Standalone OAuth wrappers with open client registration should require a registration secret. See SECURITY.md for details.
## [0.22.4] - 2026-04-26
+64
View File
@@ -0,0 +1,64 @@
# Security
## Reporting Vulnerabilities
If you discover a security issue in GBrain, please report it privately:
- **Email:** security@garrytan.com
- **GitHub:** Open a [private security advisory](https://github.com/garrytan/gbrain/security/advisories/new)
Do not open a public issue for security vulnerabilities.
## Remote MCP Security
### ⚠️ Do NOT use open OAuth client registration for remote MCP
If you deploy GBrain's MCP server behind an HTTP wrapper with OAuth 2.1
support, **never allow unauthenticated client registration**. An attacker
who discovers your server URL can:
1. Register a new OAuth client via `POST /register`
2. Use `client_credentials` grant to obtain a bearer token
3. Access all brain data via the MCP tools
### Recommended: `gbrain serve --http`
As of v0.22.5, GBrain ships a built-in HTTP transport that uses the
existing `access_tokens` table for authentication:
```bash
# Create a token
gbrain auth create "my-client"
# Start the HTTP server
gbrain serve --http --port 8787
# Connect via ngrok, Tailscale, or any tunnel
ngrok http 8787 --url your-brain.ngrok.app
```
This is the recommended way to expose GBrain remotely. No OAuth, no
registration endpoint, no self-service tokens. Tokens are managed
exclusively via `gbrain auth create/list/revoke`.
### If you must use a custom HTTP wrapper
1. **Require a secret for client registration** — check a header or body
parameter before creating new OAuth clients
2. **Disable `client_credentials` grant** — only allow `authorization_code`
with browser-based approval
3. **Restrict scopes** — never issue tokens with unlimited scope
4. **Log all token issuance** — alert on unexpected registrations
5. **Rate-limit registration and token endpoints**
### Token Management
```bash
gbrain auth create "claude-desktop" # Create a new token
gbrain auth list # List all tokens
gbrain auth revoke "claude-desktop" # Revoke a token
gbrain auth test <url> --token <tok> # Smoke-test a remote server
```
Tokens are stored as SHA-256 hashes in the `access_tokens` table. The
plaintext token is shown once at creation and never stored.
+3
View File
@@ -1,5 +1,8 @@
# Deploy GBrain Remote MCP Server
> **v0.22.5+:** Use `gbrain serve --http` for remote access. It includes built-in
> bearer token auth with no OAuth registration surface. See [SECURITY.md](../../SECURITY.md).
Access your brain from any device, any AI client. GBrain's MCP server runs locally
via `gbrain serve` (stdio). For remote access, wrap it in an HTTP server behind a
public tunnel.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.22.4",
"version": "0.22.5",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
+1 -1
View File
@@ -447,7 +447,7 @@ async function handleCliOnly(command: string, args: string[]) {
}
case 'serve': {
const { runServe } = await import('./commands/serve.ts');
await runServe(engine);
await runServe(engine, args);
return; // serve doesn't disconnect
}
case 'call': {
+15 -3
View File
@@ -1,7 +1,19 @@
import type { BrainEngine } from '../core/engine.ts';
import { startMcpServer } from '../mcp/server.ts';
import { startHttpTransport } from '../mcp/http-transport.ts';
export async function runServe(engine: BrainEngine) {
console.error('Starting GBrain MCP server (stdio)...');
await startMcpServer(engine);
export async function runServe(engine: BrainEngine, args: string[] = []) {
const useHttp = args.includes('--http');
const portIdx = args.indexOf('--port');
const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) || 8787 : 8787;
if (useHttp) {
console.error(`Starting GBrain MCP server (HTTP on port ${port})...`);
await startHttpTransport({ port, engine });
// Keep alive
await new Promise(() => {});
} else {
console.error('Starting GBrain MCP server (stdio)...');
await startMcpServer(engine);
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* HTTP transport for gbrain MCP server.
*
* `gbrain serve --http` starts an HTTP server on --port (default 8787)
* with bearer token authentication via the existing `access_tokens` table.
*
* Security model:
* - Every request must include `Authorization: Bearer <token>`
* - Tokens are validated against SHA-256 hashes in the `access_tokens` table
* - Create tokens with `gbrain auth create <name>`
* - No open OAuth registration, no client_credentials flow, no self-service tokens
*
* This replaces the need for a standalone HTTP+OAuth wrapper, which was
* vulnerable to unauthenticated client registration (see SECURITY.md).
*/
import { createHash } from 'crypto';
import type { BrainEngine } from '../core/engine.ts';
import { operations, OperationError } from '../core/operations.ts';
import type { OperationContext } from '../core/operations.ts';
import { buildToolDefs } from './tool-defs.ts';
import { VERSION } from '../version.ts';
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
interface HttpTransportOptions {
port: number;
engine: BrainEngine;
}
export async function startHttpTransport(opts: HttpTransportOptions) {
const { port, engine } = opts;
const ctx: OperationContext = { engine, remote: true };
const tools = buildToolDefs(operations);
async function validateToken(authHeader: string | null): Promise<boolean> {
if (!authHeader?.startsWith('Bearer ')) return false;
const token = authHeader.slice(7);
const hash = hashToken(token);
try {
const sql = (engine as any).sql; // internal pool
const [row] = await sql`
SELECT id FROM access_tokens
WHERE token_hash = ${hash} AND revoked_at IS NULL
`;
if (row) {
// Update last_used_at (fire-and-forget)
sql`UPDATE access_tokens SET last_used_at = now() WHERE id = ${row.id}`.catch(() => {});
}
return !!row;
} catch {
return false;
}
}
const server = Bun.serve({
port,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
// CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
},
});
}
// Health check — no auth required, no sensitive data
if (path === '/health') {
return Response.json({ status: 'ok', version: VERSION, transport: 'http' });
}
// All other endpoints require auth
const authHeader = req.headers.get('Authorization');
if (!(await validateToken(authHeader))) {
return Response.json(
{ error: 'invalid_token', message: 'Bearer token required. Create one: gbrain auth create <name>' },
{ status: 401 },
);
}
// MCP JSON-RPC endpoint
if (path === '/mcp' && req.method === 'POST') {
try {
const body = await req.json();
const { method, params, id } = body;
// tools/list
if (method === 'tools/list') {
return sseResponse({ result: { tools }, jsonrpc: '2.0', id });
}
// initialize
if (method === 'initialize') {
return sseResponse({
result: {
protocolVersion: '2025-03-26',
serverInfo: { name: 'gbrain', version: VERSION },
capabilities: { tools: {} },
},
jsonrpc: '2.0',
id,
});
}
// notifications/initialized — acknowledge
if (method === 'notifications/initialized') {
return new Response(null, { status: 204 });
}
// tools/call
if (method === 'tools/call') {
const { name, arguments: args } = params || {};
const op = operations.find(o => o.name === name);
if (!op) {
return sseResponse({
result: { content: [{ type: 'text', text: `Error: Unknown tool: ${name}` }], isError: true },
jsonrpc: '2.0',
id,
});
}
try {
const result = await op.handler(args || {}, ctx);
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
return sseResponse({
result: { content: [{ type: 'text', text }] },
jsonrpc: '2.0',
id,
});
} catch (e: any) {
const msg = e instanceof OperationError ? e.message : `Internal error: ${e.message}`;
return sseResponse({
result: { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true },
jsonrpc: '2.0',
id,
});
}
}
return Response.json({ error: 'unknown_method', message: `Unknown method: ${method}` }, { status: 400 });
} catch (e: any) {
return Response.json({ error: 'parse_error', message: e.message }, { status: 400 });
}
}
return Response.json({ error: 'not_found' }, { status: 404 });
},
});
console.error(`GBrain HTTP MCP server running on port ${port}`);
console.error(` Health: http://localhost:${port}/health`);
console.error(` MCP: http://localhost:${port}/mcp`);
console.error(` Auth: Bearer token required (create with: gbrain auth create <name>)`);
console.error('');
console.error('⚠️ Do NOT use open OAuth registration for remote MCP access.');
console.error(' Tokens are managed via: gbrain auth create/list/revoke');
return server;
}
function sseResponse(data: any): Response {
const body = `event: message\ndata: ${JSON.stringify(data)}\n\n`;
return new Response(body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
},
});
}