176 lines
10 KiB
JavaScript

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 => `<tr><td>${esc(t.name)}</td><td>${esc(t.description || '')}</td></tr>`).join('');
const debug = {
mode: probe.mode,
direct: probe.direct && { status: probe.direct.status, headers: probe.direct.headers, body: probe.direct.body },
init: probe.init && { status: probe.init.status, headers: probe.init.headers, body: probe.init.body },
listed: probe.listed && { status: probe.listed.status, headers: probe.listed.headers, body: probe.listed.body }
};
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Arrowstack MCP Info</title><style>
:root{font-family:Inter,system-ui,sans-serif;color:#e5e7eb;background:#0b1020}body{margin:0}.wrap{max-width:1200px;margin:auto;padding:32px}.top{display:flex;justify-content:space-between;gap:16px;align-items:center}.muted{color:#94a3b8}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:24px 0}.card{background:#121a2f;border:1px solid #273451;border-radius:14px;padding:18px}.big{font-size:28px;font-weight:700}.ok{color:#86efac}.bad{color:#fca5a5}table{width:100%;border-collapse:collapse;background:#121a2f;border-radius:14px;overflow:hidden}th,td{text-align:left;padding:12px;border-bottom:1px solid #273451;vertical-align:top}th{color:#93c5fd}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#080d19;padding:16px;border-radius:12px;border:1px solid #273451}a{color:#93c5fd}.pill{display:inline-block;padding:4px 9px;border-radius:999px;background:#1e293b;margin:2px}</style></head><body><div class="wrap">
<div class="top"><div><h1>Arrowstack MCP Info</h1><div class="muted">Keycloak-authenticated live MCP diagnostics</div></div><div><a href="/">Refresh</a> · <a href="/logout">Logout</a></div></div>
<div class="grid"><div class="card"><div class="muted">User</div><div class="big">${esc(claims.preferred_username || claims.email || claims.sub)}</div></div><div class="card"><div class="muted">MCP endpoint</div><div>${esc(MCP_URL)}</div></div><div class="card"><div class="muted">Direct tools/list HTTP</div><div class="big ${directStatus >= 200 && directStatus < 300 ? 'ok':'bad'}">${esc(directStatus)}</div></div><div class="card"><div class="muted">Tools discovered</div><div class="big">${tools.length}</div><div class="muted">${esc(probe.mode)}</div></div></div>
<div class="card"><h2>Access token claims (safe subset)</h2><pre>${esc(JSON.stringify(claims,null,2))}</pre></div>
<h2>MCP tools</h2>${tools.length ? `<table><thead><tr><th>Tool</th><th>Description</th></tr></thead><tbody>${toolRows}</tbody></table>` : `<div class="card bad">No tools returned. Final MCP HTTP status: ${esc(finalStatus)}. See diagnostics below.</div>`}
<h2>Protocol diagnostics</h2><div class="card"><pre>${esc(JSON.stringify(debug,null,2))}</pre></div>
</div></body></html>`;
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(html);
} catch (err) {
console.error(err);
json(res, 500, { error: 'Unhandled server error', message: err?.message || String(err) });
}
});
server.listen(PORT, '0.0.0.0', () => console.log(`mcp-info-dashboard listening on :${PORT}`));