Add Keycloak MCP diagnostics dashboard
This commit is contained in:
parent
b3832a7826
commit
5f591eadcc
175
server.js
Normal file
175
server.js
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
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 => `<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}`));
|
||||||
Loading…
x
Reference in New Issue
Block a user