UNPKG

rhombus-node-mcp

Version:
189 lines (188 loc) 7.53 kB
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import express from "express"; import { requestAuthContext } from "../auth-context.js"; import createServer from "../createServer.js"; import { logger } from "../logger.js"; // --------------------------------------------------------------------------- // x-auth-* header extraction (internal chatbot / API key clients) — UNCHANGED // --------------------------------------------------------------------------- var AuthScheme; (function (AuthScheme) { AuthScheme["API_TOKEN"] = "api-token"; AuthScheme["CHATBOT"] = "chatbot"; AuthScheme["WEB2"] = "web2"; })(AuthScheme || (AuthScheme = {})); function extractAuth(req) { const scheme = req.headers["x-auth-scheme"] ?? AuthScheme.API_TOKEN; if (scheme === AuthScheme.API_TOKEN) { const apiKey = req.headers["x-auth-apikey"] ?? process.env.RHOMBUS_API_KEY; if (!apiKey) return null; return { apiKey }; } if (scheme === AuthScheme.CHATBOT) { const sessionId = req.headers["x-auth-session"]; const latestRecordUuid = req.headers["x-auth-chat"]; if (!sessionId || !latestRecordUuid) return null; return { sessionId, latestRecordUuid }; } if (scheme === AuthScheme.WEB2) { const cookie = req.headers["x-auth-cookie"]; if (!cookie) return null; return { cookie, sessionAlias: req.headers["x-auth-session-alias"] }; } return null; } // --------------------------------------------------------------------------- // Transport // // Pure RFC 9728 OAuth 2.1 Resource Server. The Rhombus authorization server // (RFC 8414 issuer) is configured via OAUTH_AS_ISSUER_URL — e.g. set it to // the Rhombus auth host whose /.well-known/oauth-authorization-server // document advertises /authorize, /token, /register, /revoke per // RFC 6749 + 7636 + 7591 + 7009. // // The AS issues opaque Rhombus access tokens, so the MCP server does no // local validation — it just forwards the Bearer to api2 as // x-auth-access-token, which api2 already knows how to validate. // // The legacy x-auth-* dispatch for internal callers (chatbot, API-key, web2) // is unchanged. // --------------------------------------------------------------------------- export default function streamableHttpTransport() { const app = express(); app.use(express.json()); const oauthAsIssuerUrl = process.env.OAUTH_AS_ISSUER_URL; const mcpServerUrl = process.env.MCP_SERVER_URL; const allowedHost = process.env.ALLOWED_HOST; const allowedHosts = allowedHost ? allowedHost .split(",") .map(h => h.trim()) .filter(Boolean) : []; app.use(cors({ origin: ["*"], exposedHeaders: ["mcp-session-id"], allowedHeaders: [ "Content-Type", "Authorization", "mcp-session-id", "x-auth-scheme", "x-auth-apikey", "x-auth-session", "x-auth-chat", "x-auth-cookie", "x-auth-session-alias", ], })); app.get("/health", (_, res) => { res.status(200).json({ status: "ok" }); }); if (oauthAsIssuerUrl) { logger.info(`OAuth Resource Server — AS at ${oauthAsIssuerUrl}`); } else { logger.info("OAUTH_AS_ISSUER_URL not set — Bearer tokens will be rejected. Set this to the Rhombus authorization server issuer URL (e.g. https://auth-web.<env>.rhombussystems.com/)."); } // RFC 9728 — Protected Resource Metadata. Points clients at the Rhombus AS. // Preserves the issuer URL verbatim so the value matches the `issuer` field // strict OAuth clients (Claude Desktop, etc.) read from the AS metadata. app.get("/.well-known/oauth-protected-resource", (req, res) => { if (!oauthAsIssuerUrl) { res.status(404).json({ error: "oauth_not_configured" }); return; } res.json({ resource: mcpServerUrl ?? `${getSelfOrigin(req, mcpServerUrl)}/mcp`, authorization_servers: [oauthAsIssuerUrl], scopes_supported: ["rhombus:access"], bearer_methods_supported: ["header"], }); }); const handleMcpRequest = async (req, res) => { logger.info("Received MCP request"); let auth = null; const authHeader = req.headers.authorization; if (authHeader?.startsWith("Bearer ")) { if (!oauthAsIssuerUrl) { return reject401(req, res, mcpServerUrl, "OAuth not configured: set OAUTH_AS_ISSUER_URL"); } const token = authHeader.slice(7).trim(); if (!token) { return reject401(req, res, mcpServerUrl, "empty Bearer token"); } auth = { oauthBearer: token }; logger.info("MCP request authenticated via Bearer (opaque, forwarded to api2)"); } else { auth = extractAuth(req); if (!auth) { return reject401(req, res, mcpServerUrl, "no credentials presented"); } logger.info("MCP request authenticated via x-auth-* headers"); } await requestAuthContext.run(auth, async () => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, ...(allowedHosts.length > 0 ? { enableDnsRebindingProtection: true, allowedHosts } : {}), }); const server = await createServer(); await server.connect(transport); logger.info("🔗 Stateless MCP Transport connected"); await transport.handleRequest(req, res, req.body); }); }; app.post("/mcp", handleMcpRequest); app.get("/mcp", (_, res) => { res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null, })); }); app.delete("/mcp", (_, res) => { res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null, })); }); const PORT = process.env.PORT ?? 3000; app.listen(PORT, () => { logger.info(`rhombus-node-mcp listening on port ${PORT}`); }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function reject401(req, res, mcpServerUrl, msg) { const resourceMetadataUrl = `${getSelfOrigin(req, mcpServerUrl)}/.well-known/oauth-protected-resource`; res .status(401) .set("WWW-Authenticate", `Bearer error="invalid_token", error_description="${escapeWwwAuth(msg)}", resource_metadata="${resourceMetadataUrl}"`) .json({ jsonrpc: "2.0", error: { code: -32000, message: `Unauthorized: ${msg}` }, id: null, }); } function getSelfOrigin(req, mcpServerUrl) { if (mcpServerUrl) { try { return new URL(mcpServerUrl).origin; } catch { // fall through } } const proto = req.headers["x-forwarded-proto"] ?? req.protocol; const host = req.headers.host ?? "localhost"; return `${proto}://${host}`; } function escapeWwwAuth(s) { return s.replace(/["\r\n]/g, ""); }