| 1 | import { A, useNavigate, useParams } 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 { showConfirmDialog } from "../components/Dialog.tsx"; |
| 9 | import { Loaded } from "../components/Loaded.tsx"; |
| 10 | import { TabBar } from "../components/TabBar.tsx"; |
| 11 | import { toast } from "../components/Toast.tsx"; |
| 12 | import { AI } from "./AI.tsx"; |
| 13 | import { MCPAccess } from "./MCPAccess.tsx"; |
| 14 | import type { Me } from "../types/model.ts"; |
| 15 | import type { Access, Catalog } from "../types/mcp.ts"; |
| 16 | import "./MCP.css"; |
| 17 | |
| 18 | const descriptions: Record<Catalog, string> = { |
| 19 | shale: "Read and edit issues across your Shale repositories.", |
| 20 | agents: "Connect to Codex and Claude Code on your machines.", |
| 21 | observability: "Read logs and traces from your services.", |
| 22 | }; |
| 23 | |
| 24 | export function MCP(props: { me: Me }) { |
| 25 | const params = useParams<{ catalog?: string }>(); |
| 26 | const navigate = useNavigate(); |
| 27 | const ai = () => props.me.sections.includes("ai"); |
| 28 | createEffect(() => { if (!params.catalog || params.catalog === "ai" && !ai()) navigate(`/mcp/settings/${ai() ? "ai" : "observability"}`, { replace: true }); }); |
| 29 | const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get())); |
| 30 | const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale", |
| 31 | () => parseResponse(api.mcp.shale.$get())); |
| 32 | const [editing, setEditing] = createSignal(""); |
| 33 | const [selection, setSelection] = createSignal<Access>([]); |
| 34 | const [connection, { refetch: retryConnection, mutate: clearConnection }] = createResource(() => editing() || false, async (id) => { |
| 35 | clearConnection(undefined); |
| 36 | const result = await parseResponse(api.mcp.connections[":id"].$get({ param: { id } })); |
| 37 | if (editing() === id) setSelection(result.selected === "all" ? "all" : result.selected.filter((id) => result.resources.some((resource) => resource.id === id))); |
| 38 | return result; |
| 39 | }); |
| 40 | const [busy, setBusy] = createSignal(false); |
| 41 | const [code, setCode] = createSignal(""); |
| 42 | const [keyName, setKeyName] = createSignal(""); |
| 43 | const [keyMachines, setKeyMachines] = createSignal<string[]>([]); |
| 44 | const [control, setControl] = createSignal(false); |
| 45 | const [key, setKey] = createSignal(""); |
| 46 | createEffect(() => { params.catalog; setEditing(""); setKey(""); }); |
| 47 | createEffect(() => { |
| 48 | if (params.catalog !== "agents") return; |
| 49 | const events = new EventSource("/api/mcp/relay/live"); |
| 50 | events.onmessage = (event) => { |
| 51 | const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data); |
| 52 | mutate((previous) => previous && { ...previous, machines }); |
| 53 | }; |
| 54 | onCleanup(() => events.close()); |
| 55 | }); |
| 56 | const linkShale = async () => { |
| 57 | setBusy(true); |
| 58 | try { const result = await parseResponse(api.mcp.shale.$post({ json: {} })); window.location.assign(result.redirect); } |
| 59 | catch (error) { toast(reason(error)); setBusy(false); } |
| 60 | }; |
| 61 | return <div class="page mcp-page"> |
| 62 | <Loaded data={overview} what="AI / MCP settings" retry={refetch}> |
| 63 | {(data) => { |
| 64 | const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog); |
| 65 | const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id); |
| 66 | return <> |
| 67 | <header class="page-head"><h1>AI / MCP</h1></header> |
| 68 | <TabBar label="AI / MCP settings"> |
| 69 | <Show when={ai()}><A href="/mcp/settings/ai" end>AI</A></Show> |
| 70 | <For each={data().catalogs}>{(catalog) => <A href={`/mcp/settings/${catalog.id}`} end>{catalog.name}</A>}</For> |
| 71 | </TabBar> |
| 72 | <Show when={params.catalog && params.catalog !== "ai" && !catalog()}><p class="empty">No connector here.</p></Show> |
| 73 | <Show when={ai() && params.catalog === "ai"}><AI viewing={props.me.viewing} /></Show> |
| 74 | <Show when={catalog()}>{(current) => <> |
| 75 | <section class="mcp-endpoint"> |
| 76 | <p>{descriptions[current().id]}</p> |
| 77 | <div class="mcp-section-heading"><h2>Connector URL</h2><Copy value={current().endpoint} label="connector URL" /></div> |
| 78 | <p class="muted">Add this URL to your AI client’s MCP settings, then approve access.</p> |
| 79 | </section> |
| 80 | <Show when={current().id === "shale"}> |
| 81 | <section class="mcp-panel"><div class="mcp-section-heading"><h2>Shale account</h2><div class="mcp-actions"> |
| 82 | <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button> |
| 83 | <Show when={data().shale}><button class="button" disabled={busy()} onClick={() => showConfirmDialog({ title: "Unlink Shale?", description: "Every Shale client connection will lose access.", confirmLabel: "Unlink", destructive: true, onConfirm: async () => { |
| 84 | await parseResponse(api.mcp.shale.$delete()); await refetch(); await retryShale(); |
| 85 | } })}>Unlink</button></Show> |
| 86 | </div></div> |
| 87 | <Loaded data={shale} what="Shale repositories" retry={retryShale}> |
| 88 | {(account) => <Show when={account().linked} fallback={<p class="muted">Link your Shale account to connect clients.</p>}> |
| 89 | <p class="muted">Account linked<Show when={data().shale}> <Ago t={data().shale!.linkedAt} /></Show></p> |
| 90 | <details class="mcp-repositories"><summary>{account().resources.length} repositories available</summary><div class="mcp-repository-list"><For each={account().resources}>{(resource) => <div><span>{resource.name}</span><span class="muted">{resource.description}</span></div>}</For></div></details> |
| 91 | <Show when={!account().resources.length}><p class="muted">Your account has no repositories yet.</p></Show> |
| 92 | </Show>} |
| 93 | </Loaded> |
| 94 | </section> |
| 95 | </Show> |
| 96 | <Show when={current().id === "agents"}> |
| 97 | <section class="mcp-panel"> |
| 98 | <h2>Local machines</h2> |
| 99 | <details class="mcp-install"><summary>Install an agent</summary> |
| 100 | <p>Install on each machine, then enter the pairing code below. The agent starts at login.</p> |
| 101 | <h3>macOS or Linux</h3> |
| 102 | <Copy value={`curl -fsSL ${window.location.origin}/agent/install.sh | sh`} label="macOS or Linux install command" /> |
| 103 | <h3>Windows PowerShell</h3> |
| 104 | <Copy value={`irm ${window.location.origin}/agent/install.ps1 | iex`} label="Windows install command" /> |
| 105 | <p class="muted">Use your usual terminal, without administrator privileges. Codex or Claude Code must already be installed and signed in.</p></details> |
| 106 | <form class="mcp-actions" onSubmit={async (event) => { |
| 107 | event.preventDefault(); setBusy(true); |
| 108 | try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); } |
| 109 | catch (error) { toast(reason(error)); } |
| 110 | finally { setBusy(false); } |
| 111 | }}><label>Pairing code <input class="search" value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label> |
| 112 | <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form> |
| 113 | <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Run the installer on a machine to link it.</p>}> |
| 114 | <table class="data"><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody> |
| 115 | <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td> |
| 116 | <button class="button small" onClick={async () => { |
| 117 | try { await parseResponse(api.mcp.relay.machines[":id"].$delete({ param: { id: machine.id } })); await refetch(); } |
| 118 | catch (error) { toast(reason(error)); } |
| 119 | }}>Unlink</button> |
| 120 | </td></tr>}</For> |
| 121 | </tbody></table> |
| 122 | <h3>API key</h3> |
| 123 | <form onSubmit={async (event) => { |
| 124 | event.preventDefault(); setBusy(true); |
| 125 | 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(); } |
| 126 | catch (error) { toast(reason(error)); } |
| 127 | finally { setBusy(false); } |
| 128 | }}> |
| 129 | <label>Key name <input class="search" value={keyName()} onInput={(event) => setKeyName(event.currentTarget.value)} maxLength={100} /></label> |
| 130 | <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> |
| 131 | <Checkbox checked={control()} onChange={setControl}>Allow session control</Checkbox> |
| 132 | <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button> |
| 133 | </form> |
| 134 | </Show> |
| 135 | <details class="mcp-help"><summary>How to use linked machines</summary> |
| 136 | <ol> |
| 137 | <li>Add the Local agents endpoint above to your AI client's MCP connectors. Sign in and choose the machines it can access.</li> |
| 138 | <li>Ask the client to list machines, choose a target, and list or read its Codex and Claude Code chats.</li> |
| 139 | <li>To start a chat, name the machine, provider, and an existing project folder allowed during installation.</li> |
| 140 | </ol> |
| 141 | <p>Session control lets a client send messages, start chats, and interrupt turns. Desktop Codex control needs the installer's experimental option.</p> |
| 142 | <p>Linked clients can read saved chats on granted machines. Allowed project folders limit where new chats start; existing chats keep their own permissions.</p> |
| 143 | <p>For a script or another local tool, create an API key for selected machines. Enable session control only when it needs to write.</p> |
| 144 | <p>Rerun the installer to update the agent or change allowed folders. Unlink removes the machine's access immediately.</p> |
| 145 | </details> |
| 146 | <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> |
| 147 | </section> |
| 148 | </Show> |
| 149 | <section class="mcp-panel"><h2>Connected clients</h2> |
| 150 | <Show when={connections().length} fallback={<p class="muted">No clients connected. Add the connector URL to your AI client to get started.</p>}> |
| 151 | <div class="mcp-client-list"><For each={connections()}>{(client) => <article class="mcp-client"> |
| 152 | <div><h3>{client.name}</h3><p>{client.resources === "all" ? "All Repositories" : client.resources.join(", ")}</p><p class="muted">{client.scopes.includes("sessions:write") ? "Session control" : client.scopes.includes("shale:write") ? "Issue editing" : "Read only"} · Connected <Ago t={client.createdAt} /></p></div> |
| 153 | <div class="mcp-actions"><button class="button small" disabled={busy()} onClick={() => setEditing(editing() === client.id ? "" : client.id)}>Edit access</button> |
| 154 | <button class="button small" disabled={busy()} onClick={() => showConfirmDialog({ title: "Revoke this connection?", description: `${client.name} will lose access immediately.`, confirmLabel: "Revoke", destructive: true, onConfirm: async () => { |
| 155 | await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: client.id } })); if (editing() === client.id) setEditing(""); await refetch(); toast("Connection revoked"); |
| 156 | } })}>Revoke</button></div> |
| 157 | <Show when={editing() === client.id}><div class="mcp-edit-access"> |
| 158 | <Loaded data={connection} what="connection access" retry={retryConnection}> |
| 159 | {(details) => <Show when={details().linked} fallback={<><p>Link your Shale account to change repository access.</p><button class="button" disabled={busy()} onClick={linkShale}>Link account</button></>}> |
| 160 | <MCPAccess catalog={current().id} resources={details().resources} value={selection()} onChange={setSelection} disabled={busy()} /> |
| 161 | <Show when={details().resourceError}><p class="error" role="alert">{details().resourceError}</p></Show> |
| 162 | <div class="mcp-actions"><button class="button primary" disabled={busy() || (selection() !== "all" && !selection().length)} onClick={async () => { |
| 163 | setBusy(true); |
| 164 | try { await parseResponse(api.mcp.connections[":id"].$post({ param: { id: client.id }, json: { resources: selection() } })); setEditing(""); await refetch(); } |
| 165 | catch (error) { toast(reason(error)); } |
| 166 | finally { setBusy(false); } |
| 167 | }}>Save access</button><button class="button" disabled={busy()} onClick={() => setEditing("")}>Cancel</button></div> |
| 168 | </Show>} |
| 169 | </Loaded> |
| 170 | </div></Show> |
| 171 | </article>}</For></div> |
| 172 | </Show> |
| 173 | </section> |
| 174 | </>}</Show> |
| 175 | </>; |
| 176 | }} |
| 177 | </Loaded> |
| 178 | </div>; |
| 179 | } |