| 1 | import { useLocation } from "@solidjs/router"; |
| 2 | import { parseResponse } from "hono/client"; |
| 3 | import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; |
| 4 | import { api, reason } from "../api.ts"; |
| 5 | import { Ago } from "../components/Ago.tsx"; |
| 6 | import { Checkbox } from "../components/Checkbox.tsx"; |
| 7 | import { Copy } from "../components/Copy.tsx"; |
| 8 | import { Loaded } from "../components/Loaded.tsx"; |
| 9 | import { toast } from "../components/Toast.tsx"; |
| 10 | import "./MCP.css"; |
| 11 | |
| 12 | export function MCP() { |
| 13 | const location = useLocation(); |
| 14 | const request = () => new URLSearchParams(location.search).get("request"); |
| 15 | const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get())); |
| 16 | const [consent, { refetch: retryConsent }] = createResource(() => request() || false, |
| 17 | (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } }))); |
| 18 | const [picked, setPicked] = createSignal<string[]>([]); |
| 19 | const [busy, setBusy] = createSignal(false); |
| 20 | const [code, setCode] = createSignal(""); |
| 21 | const [keyName, setKeyName] = createSignal(""); |
| 22 | const [keyMachines, setKeyMachines] = createSignal<string[]>([]); |
| 23 | const [control, setControl] = createSignal(false); |
| 24 | const [key, setKey] = createSignal(""); |
| 25 | createEffect(() => { request(); setPicked([]); setBusy(false); }); |
| 26 | let events: EventSource | undefined; |
| 27 | createEffect(() => { |
| 28 | if (!overview() || events) return; |
| 29 | events = new EventSource("/api/mcp/relay/live"); |
| 30 | events.onmessage = (event) => { |
| 31 | const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data); |
| 32 | mutate((previous) => previous && { ...previous, machines }); |
| 33 | }; |
| 34 | }); |
| 35 | onCleanup(() => events?.close()); |
| 36 | const linkShale = async () => { |
| 37 | setBusy(true); |
| 38 | try { |
| 39 | const result = await parseResponse(api.mcp.shale.$post({ json: { request: consent()?.scopes.includes("shale:read") ? request() || undefined : undefined } })); |
| 40 | window.location.assign(result.redirect); |
| 41 | } catch (error) { toast(reason(error)); setBusy(false); } |
| 42 | }; |
| 43 | const answer = async (deny: boolean) => { |
| 44 | setBusy(true); |
| 45 | try { |
| 46 | const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request()! }, json: deny ? { deny: true } : { resources: picked() } })); |
| 47 | window.location.assign(result.redirect); |
| 48 | } catch (error) { toast(reason(error)); setBusy(false); } |
| 49 | }; |
| 50 | return <div class="page mcp-page"> |
| 51 | <header class="page-header"><h1>MCP</h1></header> |
| 52 | <Show when={request()}> |
| 53 | <Loaded data={consent} what="connection request" retry={retryConsent}> |
| 54 | {(details) => <section class="mcp-consent"> |
| 55 | <h2>Connect {details().client}</h2> |
| 56 | <p>{details().scopes.includes("shale:read") ? "Choose repositories this connection can read issues from." : details().scopes.includes("sessions:read") ? "Choose machines this connection can read sessions from." : "Choose services this connection can read logs and traces from."}</p> |
| 57 | <Show when={details().scopes.includes("sessions:write")}><p>This connection can send messages, start sessions, and interrupt turns on the selected machines.</p></Show> |
| 58 | <Show when={details().scopes.includes("shale:write")}><p>This connection can create issues, comment, and change issue status in the selected repositories.</p></Show> |
| 59 | <div class="mcp-resources"><For each={details().resources}>{(resource) => |
| 60 | <Checkbox checked={picked().includes(resource.id)} onChange={(checked) => setPicked(checked ? [...picked(), resource.id] : picked().filter((item) => item !== resource.id))}>{resource.name}</Checkbox> |
| 61 | }</For></div> |
| 62 | <Show when={!details().linked}><p>Link your Shale account to choose repositories.</p></Show> |
| 63 | <Show when={details().linked && !details().resources.length}><p>No resources are available to your account.</p></Show> |
| 64 | <Show when={details().scopes.includes("offline_access")}><p class="muted">This connection can refresh access without another sign-in.</p></Show> |
| 65 | <div class="mcp-actions"><Show when={details().linked} fallback={<button class="button" disabled={busy()} onClick={linkShale}>Link account</button>}><button class="button" disabled={!picked().length || busy()} onClick={() => answer(false)}>Allow access</button></Show> |
| 66 | <button class="button secondary" disabled={busy()} onClick={() => answer(true)}>Decline</button></div> |
| 67 | </section>} |
| 68 | </Loaded> |
| 69 | </Show> |
| 70 | <Loaded data={overview} what="MCP connections" retry={refetch}> |
| 71 | {(data) => <> |
| 72 | <h2>Connectors</h2> |
| 73 | <For each={data().catalogs}>{(catalog) => <section class="mcp-connector"> |
| 74 | <h3>{catalog.name}</h3><p>Add this endpoint to your AI client. Access is granted when you connect.</p> |
| 75 | <Copy value={catalog.endpoint} label="connector endpoint" /> |
| 76 | </section>}</For> |
| 77 | <section class="mcp-connector"><h3>Shale account</h3> |
| 78 | <Show when={data().shale} fallback={<p>Link your Shale account to grant clients access to its repositories.</p>}> |
| 79 | {(shale) => <p>Account linked <Ago t={shale().linkedAt} /></p>} |
| 80 | </Show> |
| 81 | <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button> |
| 82 | <Show when={data().shale}><button class="button secondary" disabled={busy()} onClick={async () => { |
| 83 | setBusy(true); |
| 84 | try { await parseResponse(api.mcp.shale.$delete()); await refetch(); } |
| 85 | catch (error) { toast(reason(error)); } |
| 86 | finally { setBusy(false); } |
| 87 | }}>Unlink</button></Show> |
| 88 | </section> |
| 89 | <h2>Local machines</h2> |
| 90 | <p>Run the Agent Relay local agent with this dashboard's origin, then enter its pairing code.</p> |
| 91 | <Copy value={`npm run agent -- run --server ${window.location.origin}`} label="local agent command" /> |
| 92 | <form class="mcp-actions" onSubmit={async (event) => { |
| 93 | event.preventDefault(); setBusy(true); |
| 94 | try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); } |
| 95 | catch (error) { toast(reason(error)); } |
| 96 | finally { setBusy(false); } |
| 97 | }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label> |
| 98 | <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form> |
| 99 | <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Pair a local agent to connect it.</p>}> |
| 100 | <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody> |
| 101 | <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td> |
| 102 | <button class="button small" onClick={async () => { |
| 103 | try { await parseResponse(api.mcp.relay.machines[":id"].$delete({ param: { id: machine.id } })); await refetch(); } |
| 104 | catch (error) { toast(reason(error)); } |
| 105 | }}>Unlink</button> |
| 106 | </td></tr>}</For> |
| 107 | </tbody></table> |
| 108 | <h3>API key</h3> |
| 109 | <form onSubmit={async (event) => { |
| 110 | event.preventDefault(); setBusy(true); |
| 111 | try { const result = await parseResponse(api.mcp.relay.keys.$post({ json: { name: keyName(), resources: keyMachines(), write: control() } })); setKey(result.key); setKeyName(""); setKeyMachines([]); setControl(false); await refetch(); } |
| 112 | catch (error) { toast(reason(error)); } |
| 113 | finally { setBusy(false); } |
| 114 | }}> |
| 115 | <label>Key name <input value={keyName()} onInput={(event) => setKeyName(event.currentTarget.value)} maxLength={100} /></label> |
| 116 | <div class="mcp-resources"><For each={data().machines}>{(machine) => <Checkbox checked={keyMachines().includes(machine.id)} onChange={(checked) => setKeyMachines(checked ? [...keyMachines(), machine.id] : keyMachines().filter((id) => id !== machine.id))}>{machine.name}</Checkbox>}</For></div> |
| 117 | <Checkbox checked={control()} onChange={setControl}>Allow session control</Checkbox> |
| 118 | <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button> |
| 119 | </form> |
| 120 | </Show> |
| 121 | <Show when={key()}><section class="mcp-connector"><h3>New API key</h3><p>Copy this key now. It won't be shown again.</p><Copy value={key()} label="API key" /><button class="button secondary" onClick={() => setKey("")}>Dismiss</button></section></Show> |
| 122 | <h2>Connections</h2> |
| 123 | <Show when={data().connections.length} fallback={<p class="muted">No clients connected yet. Add a connector endpoint to your AI client to get started.</p>}> |
| 124 | <table><thead><tr><th>Client</th><th>Access</th><th>Added</th><th /></tr></thead><tbody> |
| 125 | <For each={data().connections}>{(connection) => <tr><td>{connection.name}</td><td>{connection.resources.join(", ")}<Show when={connection.scopes.includes("sessions:write")}><span class="muted"> · Session control</span></Show><Show when={connection.scopes.includes("shale:write")}><span class="muted"> · Issue editing</span></Show></td><td><Ago t={connection.createdAt} /></td><td> |
| 126 | <button class="button small" onClick={async () => { |
| 127 | try { await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: connection.id } })); await refetch(); toast("Connection revoked"); } |
| 128 | catch (error) { toast(reason(error)); } |
| 129 | }}>Revoke</button> |
| 130 | </td></tr>}</For> |
| 131 | </tbody></table> |
| 132 | </Show> |
| 133 | </>} |
| 134 | </Loaded> |
| 135 | </div>; |
| 136 | } |