1import { useLocation } 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 { Loaded } from "../components/Loaded.tsx";
9import { toast } from "../components/Toast.tsx";
10import "./MCP.css";
11
12export 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}