import http from 'node:http'; import crypto from 'node:crypto'; const PORT = Number(process.env.PORT || 3000); const ISSUER = (process.env.KEYCLOAK_ISSUER || '').replace(/\/$/, ''); const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || ''; const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; const BASE_URL = (process.env.APP_BASE_URL || '').replace(/\/$/, ''); const MCP_URL = process.env.MCP_URL || 'https://mcp.arrowstack.dev/mcp'; const sessions = new Map(); const pending = new Map(); function b64url(buf) { return Buffer.from(buf).toString('base64url'); } function random() { return b64url(crypto.randomBytes(32)); } function cookies(req) { return Object.fromEntries((req.headers.cookie || '').split(';').filter(Boolean).map(v => { const i = v.indexOf('='); return [v.slice(0, i).trim(), decodeURIComponent(v.slice(i + 1))]; })); } function setCookie(res, name, value, maxAge = 3600) { res.setHeader('Set-Cookie', `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`); } function redirect(res, url) { res.writeHead(302, { Location: url }); res.end(); } function json(res, code, value) { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(value, null, 2)); } function decodeJwt(token) { try { return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch { return {}; } } function esc(v) { return String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function safeClaims(c) { return { sub: c.sub, preferred_username: c.preferred_username, email: c.email, aud: c.aud, azp: c.azp, scope: c.scope, resource_access: c.resource_access, realm_access: c.realm_access, groups: c.groups, exp: c.exp }; } function parseMcpBody(text) { try { return JSON.parse(text); } catch {} const events = text.split('\n').filter(l => l.startsWith('data:')).map(l => l.slice(5).trim()).filter(Boolean); for (let i = events.length - 1; i >= 0; i--) { try { return JSON.parse(events[i]); } catch {} } return { raw: text.slice(0, 3000) }; } async function mcpPost(token, payload, sessionId = '') { const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }; if (sessionId) headers['Mcp-Session-Id'] = sessionId; const r = await fetch(MCP_URL, { method: 'POST', headers, body: JSON.stringify(payload) }); const text = await r.text(); return { status: r.status, body: parseMcpBody(text), sessionId: r.headers.get('mcp-session-id') || '', headers: { 'content-type': r.headers.get('content-type'), 'www-authenticate': r.headers.get('www-authenticate'), 'mcp-session-id': r.headers.get('mcp-session-id') } }; } async function probeMcp(token) { const direct = await mcpPost(token, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); if (direct.status >= 200 && direct.status < 300 && direct.body?.result?.tools) { return { mode: 'direct tools/list', direct, tools: direct.body.result.tools }; } const init = await mcpPost(token, { jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'arrowstack-mcp-info', version: '1.0.0' } } }); if (!(init.status >= 200 && init.status < 300)) return { mode: 'direct + initialize failed', direct, init, tools: [] }; const sid = init.sessionId; await mcpPost(token, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} }, sid); const listed = await mcpPost(token, { jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }, sid); return { mode: 'initialized session', direct, init, listed, tools: listed.body?.result?.tools || [] }; } function requireConfig() { return ISSUER && CLIENT_ID && CLIENT_SECRET && BASE_URL; } const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, BASE_URL || `http://${req.headers.host}`); if (url.pathname === '/health') return json(res, 200, { ok: true, issuerConfigured: !!ISSUER, mcp: MCP_URL }); if (!requireConfig()) return json(res, 503, { error: 'OIDC configuration incomplete', issuer: !!ISSUER, clientId: !!CLIENT_ID, clientSecret: !!CLIENT_SECRET, baseUrl: !!BASE_URL }); if (url.pathname === '/login') { const state = random(); const verifier = random(); const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()); pending.set(state, { verifier, expires: Date.now() + 5 * 60_000 }); setCookie(res, 'oidc_state', state, 300); const auth = new URL(`${ISSUER}/protocol/openid-connect/auth`); auth.searchParams.set('client_id', CLIENT_ID); auth.searchParams.set('redirect_uri', `${BASE_URL}/callback`); auth.searchParams.set('response_type', 'code'); auth.searchParams.set('scope', 'openid profile email'); auth.searchParams.set('state', state); auth.searchParams.set('code_challenge', challenge); auth.searchParams.set('code_challenge_method', 'S256'); return redirect(res, auth.toString()); } if (url.pathname === '/callback') { const state = url.searchParams.get('state') || ''; const code = url.searchParams.get('code') || ''; const expected = cookies(req).oidc_state; const p = pending.get(state); pending.delete(state); if (!p || expected !== state || p.expires < Date.now() || !code) return json(res, 400, { error: 'Invalid or expired OIDC callback state' }); const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: `${BASE_URL}/callback`, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: p.verifier }); const tr = await fetch(`${ISSUER}/protocol/openid-connect/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body }); const token = await tr.json(); if (!tr.ok || !token.access_token) return json(res, 502, { error: 'Keycloak token exchange failed', status: tr.status, response: token }); const sid = random(); sessions.set(sid, { accessToken: token.access_token, claims: decodeJwt(token.access_token), created: Date.now() }); setCookie(res, 'mcp_info_session', sid, Math.min(Number(token.expires_in || 3600), 3600)); return redirect(res, '/'); } if (url.pathname === '/logout') { const sid = cookies(req).mcp_info_session; if (sid) sessions.delete(sid); setCookie(res, 'mcp_info_session', '', 0); return redirect(res, '/login'); } const sid = cookies(req).mcp_info_session; const session = sid && sessions.get(sid); if (!session) return redirect(res, '/login'); const probe = await probeMcp(session.accessToken); const claims = safeClaims(session.claims); const tools = probe.tools || []; const directStatus = probe.direct?.status ?? '-'; const finalStatus = probe.listed?.status ?? probe.init?.status ?? probe.direct?.status ?? '-'; const toolRows = tools.map(t => `
${esc(JSON.stringify(claims,null,2))}| Tool | Description |
|---|
${esc(JSON.stringify(debug,null,2))}