feat: add HTTP transport for Docker proxy support

Adds src/http-server.ts for Streamable HTTP transport, enabling the
law-mcp-proxy to route requests to this MCP via Docker network.

Endpoints: GET /health, POST /mcp, GET /mcp, DELETE /mcp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mortalus
2026-02-28 08:44:56 +01:00
parent cf6044b8e3
commit a15fe54367
2 changed files with 297 additions and 0 deletions

271
src/http-server.ts Normal file
View File

@@ -0,0 +1,271 @@
#!/usr/bin/env node
/**
* HTTP entry point for Law MCP Server (Docker proxy transport).
*
* Universal template — works with ANY law MCP that follows the standard
* pattern: registerTools() in ./tools/registry.js, capabilities.js,
* and @ansvar/mcp-sqlite database.
*
* Endpoints:
* GET /health → { status, server, version, uptime_seconds }
* POST /mcp → MCP Streamable HTTP transport (new + existing sessions)
* GET /mcp → SSE stream (existing session) or metadata (no session)
* DELETE /mcp → session termination
* OPTIONS * → CORS preflight
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createServer as createHttpServer, IncomingMessage, ServerResponse } from 'node:http';
import { randomUUID } from 'crypto';
import { createHash } from 'crypto';
import { existsSync, openSync, readSync, closeSync, readFileSync, statSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import Database from '@ansvar/mcp-sqlite';
import { registerTools } from './tools/registry.js';
import { detectCapabilities, readDbMetadata } from './capabilities.js';
// Local type — avoids import from ./tools/about.js which may not exist in all repos.
// The registerTools() `context` parameter is optional (`?`) so this is safe.
interface AboutContext {
version: string;
fingerprint: string;
dbBuilt: string;
}
// ---------------------------------------------------------------------------
// Configuration (derived from package.json — works for any law MCP)
// ---------------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PORT = parseInt(process.env.PORT || '3000', 10);
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
const SERVER_NAME: string = pkg.name.replace(/^@ansvar\//, '');
const SERVER_VERSION: string = pkg.version;
// ---------------------------------------------------------------------------
// Database resolution (standard law MCP path convention)
// ---------------------------------------------------------------------------
function resolveDbPath(): string {
// 1. Prefer *_LAW_DB_PATH env vars (most specific)
for (const [key, value] of Object.entries(process.env)) {
if (key.endsWith('_LAW_DB_PATH') && value) return value;
}
// 2. Fall back to any *_DB_PATH env var
for (const [key, value] of Object.entries(process.env)) {
if (key.endsWith('_DB_PATH') && value) return value;
}
// 3. Standard relative paths
const candidates = [
join(__dirname, '..', 'data', 'database.db'),
join(__dirname, '..', '..', 'data', 'database.db'),
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
throw new Error(
`Database not found. Set a *_DB_PATH env var or place database.db in data/`,
);
}
// ---------------------------------------------------------------------------
// Session management
// ---------------------------------------------------------------------------
/** UUID v4 pattern — prevents injection via session ID header. */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function validSessionId(raw: string | undefined): string | undefined {
if (!raw || !UUID_RE.test(raw)) return undefined;
return raw;
}
const sessions = new Map<string, StreamableHTTPServerTransport>();
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const dbPath = resolveDbPath();
const db = new Database(dbPath, { readonly: true });
db.pragma('foreign_keys = ON');
const caps = detectCapabilities(db);
const meta = readDbMetadata(db);
console.error(`[${SERVER_NAME}] Database: ${dbPath}`);
console.error(`[${SERVER_NAME}] Tier: ${meta.tier}, Capabilities: ${[...caps].join(', ')}`);
// About context for the about tool — use partial hash to avoid loading
// entire DB into memory (some are 200MB+).
let fingerprint = 'unknown';
let dbBuilt = new Date().toISOString();
try {
const SAMPLE = 64 * 1024;
const fd = openSync(dbPath, 'r');
const buf = Buffer.alloc(SAMPLE);
readSync(fd, buf, 0, SAMPLE, 0);
closeSync(fd);
fingerprint = createHash('sha256').update(buf).digest('hex').slice(0, 12);
dbBuilt = statSync(dbPath).mtime.toISOString();
} catch { /* non-fatal */ }
// Try db_metadata table for built_at (newer repos have this)
try {
const row = db.prepare("SELECT value FROM db_metadata WHERE key = 'built_at'").get() as { value: string } | undefined;
if (row) dbBuilt = row.value;
} catch { /* table may not exist */ }
const aboutContext: AboutContext = { version: SERVER_VERSION, fingerprint, dbBuilt };
/** Create a fresh MCP server instance (one per session). */
function createMCPServer(): Server {
const server = new Server(
{ name: SERVER_NAME, version: SERVER_VERSION },
{ capabilities: { tools: {} } },
);
registerTools(server, db, aboutContext);
return server;
}
// -------------------------------------------------------------------------
// HTTP server
// -------------------------------------------------------------------------
const httpServer = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
const url = new URL(req.url || '/', `http://localhost:${PORT}`);
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id');
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
try {
// OPTIONS — preflight
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// GET /health
if (url.pathname === '/health' && req.method === 'GET') {
let dbOk = false;
try {
db.prepare('SELECT 1').get();
dbOk = true;
} catch { /* DB not healthy */ }
res.writeHead(dbOk ? 200 : 503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: dbOk ? 'ok' : 'degraded',
server: SERVER_NAME,
version: SERVER_VERSION,
uptime_seconds: Math.floor(process.uptime()),
}));
return;
}
// /mcp — MCP Streamable HTTP transport
if (url.pathname === '/mcp') {
const sessionId = validSessionId(req.headers['mcp-session-id'] as string | undefined);
// Existing session — delegate
if (sessionId && sessions.has(sessionId)) {
await sessions.get(sessionId)!.handleRequest(req, res);
return;
}
// DELETE — session termination (no existing session found)
if (req.method === 'DELETE') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
return;
}
// POST — new session (initialize)
if (req.method === 'POST') {
// Pre-generate sessionId so we can store it before handleRequest.
// This eliminates a race where the client sends a follow-up request
// between handleRequest completing and sessions.set() executing.
const newSessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => newSessionId,
});
sessions.set(newSessionId, transport);
transport.onclose = () => {
sessions.delete(newSessionId);
};
const server = createMCPServer();
await server.connect(transport);
await transport.handleRequest(req, res);
return;
}
// GET without session — metadata
if (req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
name: SERVER_NAME,
version: SERVER_VERSION,
protocol: 'mcp',
transport: 'streamable-http',
}));
return;
}
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Bad request — missing or invalid session' }));
return;
}
// 404
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
} catch (error) {
console.error(`[${SERVER_NAME}] Unhandled error:`, error);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
}
});
httpServer.listen(PORT, () => {
console.error(`${SERVER_NAME} v${SERVER_VERSION} HTTP server listening on port ${PORT}`);
});
// -------------------------------------------------------------------------
// Graceful shutdown
// -------------------------------------------------------------------------
const shutdown = (signal: string) => {
console.error(`[${SERVER_NAME}] Shutting down (${signal})...`);
for (const [, t] of sessions) t.close().catch(() => {});
sessions.clear();
try { db.close(); } catch { /* ignore */ }
httpServer.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
main().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});