1import { A, useNavigate, useParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
4import { api, reason } from "../api.ts";
5import { Ago } from "../components/Ago.tsx";
6import { Checkbox } from "../components/Checkbox.tsx";
7import { Copy } from "../components/Copy.tsx";
8import { showConfirmDialog } from "../components/Dialog.tsx";
9import { Loaded } from "../components/Loaded.tsx";
10import { TabBar } from "../components/TabBar.tsx";
11import { toast } from "../components/Toast.tsx";
12import { AI } from "./AI.tsx";
13import { MCPAccess } from "./MCPAccess.tsx";
14import type { Me } from "../types/model.ts";
15import type { Access, Catalog } from "../types/mcp.ts";
16import "./MCP.css";
17
18const 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
24export 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}