1import { useBeforeLeave, useLocation, useParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import { createEffect, createResource, createSignal, on, onCleanup, onMount, Show } from "solid-js";
4import { api, reason } from "../api.ts";
5import { showConfirmDialog } from "../components/Dialog.tsx";
6import { MCPAccess } from "./MCPAccess.tsx";
7import type { Access } from "../types/mcp.ts";
8import "./MCP.css";
9
10export function MCPConsent() {
11 const params = useParams<{ id: string }>();
12 const location = useLocation();
13 const request = () => params.id || new URLSearchParams(location.search).get("request") || "";
14 const [consent, { refetch }] = createResource(request,
15 (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } })));
16 const details = () => consent.state === "ready" ? consent.latest : undefined;
17 const [selection, setSelection] = createSignal<Access>();
18 const access = () => selection() ?? (details()?.catalog === "shale" ? "all" : []);
19 const [busy, setBusy] = createSignal(false);
20 const [error, setError] = createSignal("");
21 let leaving = false;
22 createEffect(on(request, () => { setSelection(undefined); setError(""); }));
23 const redirect = (target: string) => { leaving = true; window.location.assign(target); };
24 const answer = async (deny: boolean) => {
25 setBusy(true); setError("");
26 try {
27 const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request() }, json: deny ? { deny: true } : { resources: access() } }));
28 redirect(result.redirect);
29 } catch (error) { setError(reason(error)); setBusy(false); throw error; }
30 };
31 useBeforeLeave((event) => {
32 if (leaving) return;
33 event.preventDefault();
34 if (busy()) return;
35 showConfirmDialog({ title: "Decline this connection?", description: "The client will receive no access.", confirmLabel: "Decline", onConfirm: () => answer(true) });
36 });
37 onMount(() => {
38 const guard = (event: BeforeUnloadEvent) => { if (!leaving) event.preventDefault(); };
39 window.addEventListener("beforeunload", guard);
40 onCleanup(() => window.removeEventListener("beforeunload", guard));
41 });
42 const linkShale = async () => {
43 setBusy(true); setError("");
44 try {
45 const result = await parseResponse(api.mcp.shale.$post({ json: { request: request() } }));
46 redirect(result.redirect);
47 } catch (error) { setError(reason(error)); setBusy(false); }
48 };
49 return <main class="mcp-authorization">
50 <section class="mcp-approval" aria-labelledby="connection-title">
51 <div class="mcp-approval-brand">Snowglobe <span class="muted">· MCP connection</span></div>
52 <Show when={details()} fallback={<>
53 <h1 id="connection-title">{consent.state === "errored" ? "Connection unavailable" : "Loading connection…"}</h1>
54 <Show when={consent.state === "errored"} fallback={<div class="skeleton" style={{ height: "120px" }} aria-label="Loading connection" />}>
55 <p class="error" role="alert">{reason(consent.error)}</p>
56 <button class="button" onClick={() => refetch()}>Retry</button>
57 </Show>
58 </>}>
59 {(data) => <>
60 <h1 id="connection-title">Connect {data().client}</h1>
61 <p class="mcp-approval-intro">{data().catalog === "shale" ? "Grant access to Shale issues." : data().catalog === "agents" ? "Grant access to your local agents." : "Grant access to logs and traces."}</p>
62 <dl class="mcp-request-identity"><div><dt>Signed in as</dt><dd>{data().account}</dd></div><div><dt>Return to</dt><dd>{data().redirectHost}</dd></div></dl>
63 <div class="mcp-permissions"><h2>Requested access</h2>
64 <p>{data().catalog === "shale" ? "Read issues and comments" : data().catalog === "agents" ? "Read saved chats" : "Read logs and traces"}</p>
65 <Show when={data().scopes.includes("shale:write")}><p>Create issues, comment, and edit issue titles and status</p></Show>
66 <Show when={data().scopes.includes("sessions:write")}><p>Send messages, start chats, and interrupt turns</p></Show>
67 <Show when={data().scopes.includes("offline_access")}><p>Stay connected without signing in again</p></Show>
68 </div>
69 <Show when={data().linked} fallback={<div class="mcp-link-step"><h2>Link your Shale account</h2><p>Sign in to Shale, then return here to approve access.</p><button class="button primary" disabled={busy()} onClick={linkShale}>Link account</button></div>}>
70 <MCPAccess catalog={data().catalog} resources={data().resources} value={access()} onChange={setSelection} disabled={busy()} />
71 <Show when={data().resourceError}><p class="error" role="alert">{data().resourceError} <button class="button small" disabled={busy()} onClick={() => refetch()}>Retry</button></p></Show>
72 <p class="muted">You can change this connection’s access later in MCP settings.</p>
73 </Show>
74 </>}
75 </Show>
76 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
77 <div class="mcp-approval-actions">
78 <button class="button" disabled={busy()} onClick={() => answer(true).catch(() => {})}>Decline</button>
79 <Show when={details()?.linked}><button class="button primary" disabled={busy() || (access() !== "all" && !access().length)} onClick={() => answer(false).catch(() => {})}>Allow access</button></Show>
80 </div>
81 <Show when={consent.state === "errored"}><button class="mcp-close" onClick={() => redirect("/mcp")}>Close request</button></Show>
82 </section>
83 </main>;
84}