Judge r1 (grade D) blockers, each fixed and tested:
1. TRANSPORT: custom POST-only JSON-RPC → MCP 2025-03-10 Streamable HTTP
(spec-canonical; replaced HTTP+SSE). POST answers application/json for
requests; valid notifications -> 202 empty; malformed notifications ->
no reply (204); batching removed in this revision -> arrays rejected
-32600. GET (server->client stream) and DELETE (session termination)
answer 405 + Allow: POST — the spec's explicit allowance for stateless
request/response servers; documented in /info + modal + OpenAPI.
Origin header validated when present (DNS-rebinding MUST); Accept
negotiation (json required, else 406); MCP-Protocol-Version header
checked when sent. Clients needing server-initiated streams use the
stdio bridge (unchanged, shares mcp-core).
2. CREDENTIAL TYPE PRESERVATION: routes/mcp.js now forwards the SAME
scheme the caller authenticated with (req.auth.type from
middleware.js:549-586): apikey -> x-api-key, jwt -> Authorization
Bearer (NOT dk_-coerced-x-api-key as r1 did). mcp-core createToolHandler
accepts explicit headers. Tests assert BOTH shapes downstream
(apikey carries x-api-key & no Authorization; jwt carries Bearer & no
x-api-key; zero downstream calls for session denial).
3. JSON-RPC VALIDATION: validateJsonRpc — jsonrpc:'2.0', non-empty string
method, params object-or-omitted, id string|number|null. Invalid
Request -32600 (empty body, empty array, batch); tools/call without
string params.name -> -32602; unknown method -> -32601; error envelopes
carry id null when unpairable. Transport primitives rejected by the
app-wide strict JSON parser (400; asserted).
4. REPRODUCIBILITY: status/dist/features.js + sw.js regenerated and
committed IN THE PATCH (repo convention: DC-130/131 committed bundle
rebuilds). Verifier greps the built bundle for aiconn- (23),
mcp/connections (1), httpConfig (2) so a clean `git am` checkout serves
the real frontend.
Judge r1 polish, all applied:
- verifier accepts 0_PATCH.diff / PATCH.diff naming (indexed artifact);
- connections list filtered server-side to "AI connection (...)" keys
(new GET /mcp/connections, session-only; modal switched to it);
- URL-encoded tool path params (serviceId, containerId, action,
templateId, category, zone) in mcp-core.
Config shape: POST /mcp/config (session-only; apikey 403) returns the
standard Streamable-HTTP card {url, transport: streamable-http, headers:
{x-api-key}, mcpProtocolVersion} + per-client note (ChatGPT note states
the public-https requirement honestly; no fabricated connector claims).
Tests: 57 green (34 route incl. auth matrix/type-preservation/transport
rules/JSON-RPC errors + 23 core). Composition-root e2e (8) boots the real
createApp with the mount. eslint 0 errors. Full suite: only the
pre-existing pdf-parse billing family red (unchanged since base 470dfaa).
173 lines
9.0 KiB
JavaScript
173 lines
9.0 KiB
JavaScript
// ========== CONNECT YOUR AI (DC-162) ==========
|
|
// One button, one flow: click "🤖 Connect AI" → pick Claude/ChatGPT/generic
|
|
// → DashCaddy mints a dedicated dk_ key + ready-to-paste MCP config. The
|
|
// dashboard's own session does the signing-in; the AI assistant authenticates
|
|
// with the key against POST /api/v1/mcp. Keys listed/revokeable here too.
|
|
(function() {
|
|
const errorHandler = new ErrorHandler();
|
|
|
|
injectModal('ai-connect-modal', `<div id="ai-connect-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width:560px;max-width:720px;">
|
|
<h3>🤖 Connect your AI assistant</h3>
|
|
<div id="aiconn-intro" style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;">
|
|
DashCaddy speaks MCP — Claude, ChatGPT and other AI assistants can manage your
|
|
homelab (deploy apps, check health, edit DNS) through one endpoint.
|
|
Pick an assistant and DashCaddy generates the access key + config for it.
|
|
</div>
|
|
|
|
<div id="aiconn-endpoint" style="display:none;margin:0 0 12px;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:0.8rem;">
|
|
<span style="color:var(--muted);">MCP endpoint:</span>
|
|
<code id="aiconn-endpoint-url" style="user-select:all;">—</code>
|
|
<button id="aiconn-copy-endpoint" style="margin-left:8px;font-size:0.75rem;">Copy</button>
|
|
<div id="aiconn-tools" style="color:var(--muted);margin-top:2px;"></div>
|
|
</div>
|
|
|
|
<!-- Step 1: pick client -->
|
|
<div id="aiconn-step-pick">
|
|
<div style="font-weight:600;margin:6px 0 8px;">Choose your assistant:</div>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
|
<button class="aiconn-client-btn" data-client="claude">🟠 Claude</button>
|
|
<button class="aiconn-client-btn" data-client="chatgpt">🟢 ChatGPT</button>
|
|
<button class="aiconn-client-btn" data-client="generic">🔌 Other / generic</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: the generated connection -->
|
|
<div id="aiconn-step-config" style="display:none;margin-top:12px;">
|
|
<div style="font-weight:600;margin-bottom:6px;" id="aiconn-config-title">Your access key (shown once):</div>
|
|
<div style="display:flex;gap:6px;align-items:center;margin-bottom:10px;">
|
|
<code id="aiconn-key" style="flex:1;padding:6px 8px;border:1px solid var(--accent);border-radius:4px;background:color-mix(in srgb, var(--accent) 8%, transparent);user-select:all;word-break:break-all;">—</code>
|
|
<button id="aiconn-copy-key">Copy</button>
|
|
</div>
|
|
<div style="font-weight:600;margin-bottom:6px;">Paste into your client:</div>
|
|
<pre id="aiconn-snippet" style="white-space:pre-wrap;word-break:break-all;font-size:0.78rem;border:1px solid var(--border);border-radius:6px;padding:10px;max-height:220px;overflow:auto;margin:0 0 8px;"></pre>
|
|
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px;">
|
|
<button id="aiconn-copy-snippet">Copy config</button>
|
|
<button id="aiconn-back">← Choose a different assistant</button>
|
|
</div>
|
|
<div id="aiconn-client-note" style="font-size:0.78rem;color:var(--muted);margin-bottom:10px;"></div>
|
|
</div>
|
|
|
|
<!-- Existing connections -->
|
|
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);">
|
|
<div style="font-weight:600;margin-bottom:6px;">Active AI connections</div>
|
|
<div id="aiconn-connections" style="display:flex;flex-direction:column;gap:4px;max-height:160px;overflow-y:auto;">
|
|
<div style="font-size:0.8rem;color:var(--muted);">Loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<span id="aiconn-status" style="flex:1;font-size:0.78rem;color:var(--muted);align-self:center;"></span>
|
|
<button id="aiconn-close">Close</button>
|
|
</div>
|
|
</div></div>`);
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
let info = null;
|
|
|
|
function setStatus(msg) { $('aiconn-status').textContent = msg; }
|
|
|
|
async function loadInfo() {
|
|
try {
|
|
const resp = await secureFetch('/api/v1/mcp/info');
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const body = await resp.json();
|
|
info = body;
|
|
$('aiconn-endpoint').style.display = 'block';
|
|
$('aiconn-endpoint-url').textContent = body.endpoint;
|
|
$('aiconn-tools').textContent = `${body.toolCount} tools · MCP ${body.protocolVersion} · DashCaddy v${body.serverVersion}`;
|
|
} catch (e) {
|
|
errorHandler.log(e, 'ai-connect-info');
|
|
setStatus('MCP endpoint unavailable on this build.');
|
|
}
|
|
}
|
|
|
|
async function loadConnections() {
|
|
const listEl = $('aiconn-connections');
|
|
try {
|
|
const resp = await secureFetch('/api/v1/mcp/connections');
|
|
const body = await resp.json().catch(() => ({}));
|
|
const keys = (body && Array.isArray(body.connections)) ? body.connections : [];
|
|
if (!keys.length) {
|
|
listEl.innerHTML = '<div style="font-size:0.8rem;color:var(--muted);">No connections yet — choose an assistant above.</div>';
|
|
return;
|
|
}
|
|
listEl.innerHTML = keys.map(k => `
|
|
<div style="display:flex;gap:8px;align-items:center;font-size:0.8rem;">
|
|
<span style="flex:1;">${escapeHtml(k.name)}
|
|
<span style="color:var(--muted);">· ${(k.scopes || []).join(',')} · since ${escapeHtml((k.createdAt || '').slice(0, 10))}${k.lastUsed ? ' · last used ' + escapeHtml(String(k.lastUsed).slice(0, 10)) : ''}</span></span>
|
|
<button class="aiconn-revoke" data-id="${escapeHtml(k.id)}" style="font-size:0.75rem;color:var(--err-fg);">Revoke</button>
|
|
</div>`).join('');
|
|
listEl.querySelectorAll('.aiconn-revoke').forEach(btn => btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
const resp = await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' });
|
|
if (resp.ok) { setStatus('Connection revoked.'); loadConnections(); }
|
|
else { const b = await resp.json().catch(() => ({})); setStatus(b.error || 'Revoke failed.'); btn.disabled = false; }
|
|
} catch (e) { setStatus('Revoke failed: ' + e.message); btn.disabled = false; }
|
|
}));
|
|
} catch (e) {
|
|
listEl.innerHTML = '<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>';
|
|
}
|
|
}
|
|
|
|
async function createConnection(client) {
|
|
setStatus(`Creating ${client} connection…`);
|
|
try {
|
|
const resp = await secureFetch('/api/v1/mcp/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ client }),
|
|
});
|
|
const body = await resp.json().catch(() => ({}));
|
|
if (!resp.ok || !body.key) {
|
|
setStatus(body.error || `Failed (HTTP ${resp.status})`);
|
|
return;
|
|
}
|
|
$('aiconn-key').textContent = body.key;
|
|
// rev2 config shape: httpConfig = {url, transport, headers,
|
|
// mcpProtocolVersion} — the standard Streamable-HTTP connection card.
|
|
const snippet = body.httpConfig ? JSON.stringify(body.httpConfig, null, 2) : '';
|
|
$('aiconn-snippet').textContent = snippet || '(see note below)';
|
|
$('aiconn-client-note').textContent = body.note || '';
|
|
$('aiconn-config-title').textContent =
|
|
client === 'chatgpt' ? 'Access key (ChatGPT note below):' : 'Your access key (shown once):';
|
|
$('aiconn-step-pick').style.display = 'none';
|
|
$('aiconn-step-config').style.display = 'block';
|
|
setStatus('');
|
|
loadConnections();
|
|
} catch (e) {
|
|
errorHandler.log(e, 'ai-connect-create');
|
|
setStatus('Failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function copyText(text, okMsg) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(() => setStatus(okMsg)).catch(() => setStatus('Copy failed — select manually.'));
|
|
} else {
|
|
setStatus('Clipboard unavailable — select manually.');
|
|
}
|
|
}
|
|
|
|
// ---- wiring ----
|
|
$('ai-connect-btn').addEventListener('click', () => {
|
|
$('ai-connect-modal').style.display = 'flex';
|
|
$('aiconn-step-pick').style.display = 'block';
|
|
$('aiconn-step-config').style.display = 'none';
|
|
setStatus('');
|
|
loadInfo();
|
|
loadConnections();
|
|
});
|
|
$('aiconn-close').addEventListener('click', () => { $('ai-connect-modal').style.display = 'none'; });
|
|
$('aiconn-back').addEventListener('click', () => {
|
|
$('aiconn-step-config').style.display = 'none';
|
|
$('aiconn-step-pick').style.display = 'block';
|
|
});
|
|
document.querySelectorAll('.aiconn-client-btn').forEach(btn =>
|
|
btn.addEventListener('click', () => createConnection(btn.dataset.client)));
|
|
$('aiconn-copy-key').addEventListener('click', () => copyText($('aiconn-key').textContent, 'Key copied — paste it into your client.'));
|
|
$('aiconn-copy-snippet').addEventListener('click', () => copyText($('aiconn-snippet').textContent, 'Config copied.'));
|
|
$('aiconn-copy-endpoint').addEventListener('click', () => copyText(info ? info.endpoint : $('aiconn-endpoint-url').textContent, 'Endpoint copied.'));
|
|
})();
|