granttap-mcp 0.8.8 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ # Connection center
2
+
3
+ `../mcp-tools/connect.ts` registers the public `connection_status`, `connect`,
4
+ and confirmed `reconnect` tools. `state.ts` owns the process-local pending QR
5
+ and sanitized connection snapshot. `../mcp-tools/connection-widget.ts` serves
6
+ `widget.html`, `bridge.js`, and `view.js` as a self-contained MCP Apps resource.
7
+
8
+ Opening the center does not create keys, start a relay connection, or install
9
+ hooks. Connect preserves an existing machine identity, including when the local
10
+ phone export is absent. Reconnect requires explicit confirmation. A pending QR
11
+ is reusable until expiry in this MCP process; restarting the process loses the
12
+ transfer code but preserves the pairing. It never persists transfer secrets.
13
+
14
+ Saved keys, this process's relay socket, and recent authenticated phone messages
15
+ are distinct. Unknown phone availability stays unknown. Provider readiness is
16
+ configuration information, not evidence that the provider is currently running.
17
+ A phone observation clears the pending QR. Expired QR images and copy actions
18
+ are removed; reconnect can issue a replacement after confirmation.
19
+
20
+ The UI initializes the MCP Apps bridge before invoking tools, bounds waits,
21
+ checks parent messages, refreshes pending pairing while visible, and keeps
22
+ copyable transfer material out of model-visible text and persistent UI storage.
23
+ Non-UI clients can still use the tools and display the returned QR image.
24
+
25
+ The local stdio plugin does not have a separate account login. The Codex-owned
26
+ plugin management page is not this UI. Its native OAuth sign-in controls apply
27
+ to OAuth-capable HTTP MCP servers; this module does not add an account service
28
+ or replace the local provider runtime.
29
+
30
+ Tests in `tests/` exercise isolated keys, a loopback encrypted phone connection,
31
+ expiry, non-mutating status, and actual DOM button/host-message behavior.
32
+
33
+ License: this module is distributed under the GrantTap Commercial Source License
34
+ in the repository-root `LICENSE` file.
@@ -0,0 +1,83 @@
1
+ // MCP Apps bridge. Pairing secrets stay in this iframe; never persist UI state.
2
+ let nextId = 0;
3
+ const requests = new Map();
4
+ let hostOrigin = null;
5
+ let bridgeReady = false;
6
+ let legacyBridge = false;
7
+ function post(message) {
8
+ window.parent.postMessage({ jsonrpc: "2.0", ...message }, hostOrigin || "*");
9
+ }
10
+ function request(method, params) {
11
+ const id = ++nextId;
12
+ return new Promise((resolve, reject) => {
13
+ const timer = setTimeout(() => {
14
+ requests.delete(id);
15
+ reject(new Error("No response. Refresh status before retrying."));
16
+ }, 20000);
17
+ requests.set(id, { resolve, reject, timer });
18
+ post({ id, method, params });
19
+ });
20
+ }
21
+ window.addEventListener("message", event => {
22
+ if (event.source !== window.parent || event.data?.jsonrpc !== "2.0") return;
23
+ if (hostOrigin && event.origin !== hostOrigin) return;
24
+ const data = event.data;
25
+ const pending = requests.get(data.id);
26
+ if (pending) {
27
+ if (event.origin && event.origin !== "null") hostOrigin = event.origin;
28
+ requests.delete(data.id);
29
+ clearTimeout(pending.timer);
30
+ if (data.error) pending.reject(new Error("The host could not complete this request."));
31
+ else pending.resolve(data.result);
32
+ } else if (data.method === "ui/notifications/tool-result") {
33
+ render(data.params);
34
+ } else if (data.method === "ui/notifications/tool-cancelled") {
35
+ message("Request cancelled. Refresh status to check the current state.");
36
+ }
37
+ });
38
+ async function initializeBridge() {
39
+ try {
40
+ const initialized = await request("ui/initialize", {
41
+ appInfo: { name: "GrantTap connection center", version: "2.0.0" },
42
+ appCapabilities: {}, protocolVersion: "2026-01-26",
43
+ });
44
+ if (initialized?.protocolVersion !== "2026-01-26") throw new Error("Unsupported UI protocol");
45
+ post({ method: "ui/notifications/initialized" });
46
+ bridgeReady = true;
47
+ } catch {
48
+ if (!window.openai?.callTool) {
49
+ message("Interactive controls are unavailable. Ask GrantTap to check connection status or show your pairing QR.");
50
+ return;
51
+ }
52
+ legacyBridge = true;
53
+ bridgeReady = true;
54
+ }
55
+ setBusy(false);
56
+ await call("connection_status");
57
+ }
58
+ async function call(name, args = {}) {
59
+ if (!bridgeReady || busy) return;
60
+ setBusy(true);
61
+ message("Checking…");
62
+ try {
63
+ const result = legacyBridge
64
+ ? await legacyCall(name, args)
65
+ : await request("tools/call", { name, arguments: args });
66
+ if (result?.isError) message("Could not complete the request. Refresh status and check the relay before retrying.");
67
+ else { render(result); message("Status updated."); }
68
+ } catch {
69
+ message("No successful response. Refresh status before retrying; pairing may already have changed.");
70
+ } finally {
71
+ setBusy(false);
72
+ }
73
+ }
74
+
75
+ async function legacyCall(name, args) {
76
+ let timer;
77
+ try {
78
+ return await Promise.race([
79
+ window.openai.callTool(name, args),
80
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("No response")), 20000); }),
81
+ ]);
82
+ } finally { clearTimeout(timer); }
83
+ }
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import { hostname } from "node:os";
3
+ import { loadConfig } from "../../../bridge/src/config";
4
+ import { isMachineConfigured, readOnlyMachineConfigPath } from "../pairing-status";
5
+ import { inspectProviderStatusSnapshot } from "../provider-status";
6
+ import { packageVersion } from "../package-version";
7
+ import { connectionRuntimeStatus } from "../mcp-tools/relay";
8
+
9
+ export const connectionOutput = {
10
+ status: z.enum(["disconnected", "paired", "pairing", "expired", "connected"]),
11
+ computer: z.string(),
12
+ version: z.string(),
13
+ relay: z.string(),
14
+ relayStatus: z.enum(["online", "offline", "unknown"]),
15
+ phoneLastSeenAt: z.number().nullable(),
16
+ expiresAt: z.number().nullable(),
17
+ expiresInMinutes: z.number().int().positive().nullable(),
18
+ providers: z.array(z.object({ id: z.string(), status: z.string(), detail: z.string() })),
19
+ };
20
+
21
+ export type PendingCode = {
22
+ room: string;
23
+ expiresAt: number;
24
+ pairingUri: string;
25
+ qrDataUrl: string;
26
+ };
27
+
28
+ /** Ephemeral transfer material never enters logs, structured output, or disk. */
29
+ export class ConnectionState {
30
+ private pending: PendingCode | null = null;
31
+
32
+ remember(code: PendingCode): void {
33
+ this.pending = code;
34
+ }
35
+
36
+ snapshot(now = Date.now()) {
37
+ const config = isMachineConfigured() ? loadConfig(readOnlyMachineConfigPath()) : null;
38
+ const runtime = connectionRuntimeStatus(config?.room);
39
+ if (!config || this.pending?.room !== config.room || runtime.phoneLastSeenAt) this.pending = null;
40
+ const expired = this.pending !== null && this.pending.expiresAt <= now;
41
+ const status = !config ? "disconnected" : this.pending
42
+ ? expired ? "expired" : "pairing"
43
+ : runtime.phoneLastSeenAt && now - runtime.phoneLastSeenAt < 60_000 ? "connected" : "paired";
44
+ const code = expired ? null : this.pending;
45
+ return {
46
+ structuredContent: {
47
+ status,
48
+ computer: hostname(),
49
+ version: packageVersion(),
50
+ relay: config ? new URL(config.relayUrl).host : "",
51
+ relayStatus: runtime.relayStatus,
52
+ phoneLastSeenAt: runtime.phoneLastSeenAt,
53
+ expiresAt: this.pending?.expiresAt ?? null,
54
+ expiresInMinutes: code ? Math.max(1, Math.ceil((code.expiresAt - now) / 60_000)) : null,
55
+ providers: inspectProviderStatusSnapshot().providers,
56
+ },
57
+ _meta: { granttap: code ? { pairingUri: code.pairingUri, qrDataUrl: code.qrDataUrl } : {} },
58
+ };
59
+ }
60
+ }
@@ -0,0 +1,99 @@
1
+ const $ = id => document.getElementById(id);
2
+ let busy = false;
3
+ let pairingUri = "";
4
+ let current = {};
5
+ function message(text) { $("message").textContent = text; }
6
+ function setBusy(value) {
7
+ busy = value;
8
+ document.querySelectorAll("button").forEach(button => { button.disabled = value; });
9
+ }
10
+ function clearCode() {
11
+ pairingUri = "";
12
+ $("qr").removeAttribute("src");
13
+ $("qr").classList.add("hidden");
14
+ $("copy").classList.add("hidden");
15
+ }
16
+ function render(value) {
17
+ const result = value?.mcp_tool_result ?? value?.call_tool_result ?? value;
18
+ const state = result?.structuredContent ?? result?.structured_content;
19
+ if (!state?.status || result.isError) return;
20
+ current = state;
21
+ clearCode();
22
+ const meta = result._meta?.granttap ?? {};
23
+ const labels = {
24
+ disconnected: ["Not paired", "Connect this computer", "Connect to create a one-time QR. No account or password is required."],
25
+ pairing: ["Waiting for iPhone", "Scan to connect", "Open GrantTap on iPhone → Settings → Connections. Scan this one-time QR or copy the pairing link."],
26
+ expired: ["QR expired", "Create a new pairing code", "Choose Reconnect and confirm replacing the pairing. The previous QR can no longer be used."],
27
+ paired: ["Pairing saved", "This computer is paired", "Phone activity has not been confirmed recently by this MCP. Refresh status or open GrantTap on iPhone."],
28
+ connected: ["iPhone activity confirmed", "Your iPhone has responded", "An encrypted message was received recently. This does not guarantee continuous reachability."],
29
+ };
30
+ const [status, heading, detail] = labels[state.status] || labels.disconnected;
31
+ $("card").className = "card " + (state.status === "connected" ? "connected" : "");
32
+ $("status").textContent = status;
33
+ $("heading").textContent = heading;
34
+ $("detail").textContent = detail;
35
+ $("connect").classList.toggle("hidden", state.status !== "disconnected");
36
+ $("reconnect").classList.toggle("hidden", state.status === "disconnected");
37
+ if (state.status === "pairing" && state.expiresAt > Date.now()) {
38
+ if (/^granttap:\/\/pair-v2\?/.test(meta.pairingUri || "")) pairingUri = meta.pairingUri;
39
+ if (/^data:image\/png;base64,/.test(meta.qrDataUrl || "")) {
40
+ $("qr").src = meta.qrDataUrl;
41
+ $("qr").classList.remove("hidden");
42
+ }
43
+ $("copy").classList.toggle("hidden", !pairingUri);
44
+ }
45
+ $("computer").textContent = state.computer || "Unknown";
46
+ $("version").textContent = state.version || "Unknown";
47
+ $("relay").textContent = (state.relay || "Not configured") + " · " + (state.relayStatus || "unknown");
48
+ $("phone").textContent = state.phoneLastSeenAt ? new Date(state.phoneLastSeenAt).toLocaleString() : "Not observed by this MCP";
49
+ $("providers").replaceChildren();
50
+ for (const provider of state.providers || []) {
51
+ const row = document.createElement("li");
52
+ row.textContent = `${provider.id}: ${provider.detail}`;
53
+ $("providers").append(row);
54
+ }
55
+ updateExpiry();
56
+ }
57
+ function updateExpiry() {
58
+ const remaining = Math.ceil((current.expiresAt - Date.now()) / 1000);
59
+ $("expiry").textContent = current.status === "pairing" && remaining > 0
60
+ ? `One-time code expires in ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, "0")}` : "";
61
+ if (current.status === "pairing" && remaining <= 0) {
62
+ clearCode();
63
+ $("status").textContent = "QR expired";
64
+ $("detail").textContent = "Choose Reconnect to replace the expired pairing code.";
65
+ }
66
+ }
67
+ $("connect").addEventListener("click", () => call("connect"));
68
+ $("refresh").addEventListener("click", () => call("connection_status"));
69
+ $("reconnect").addEventListener("click", () => {
70
+ $("confirm").classList.remove("hidden");
71
+ $("cancel").focus();
72
+ });
73
+ $("cancel").addEventListener("click", () => $("confirm").classList.add("hidden"));
74
+ $("confirm-reconnect").addEventListener("click", () => {
75
+ $("confirm").classList.add("hidden");
76
+ call("reconnect", { confirmed: true });
77
+ });
78
+ $("copy").addEventListener("click", async () => {
79
+ updateExpiry();
80
+ if (!pairingUri) return;
81
+ try { await navigator.clipboard.writeText(pairingUri); message("Pairing link copied."); }
82
+ catch { message("Clipboard is unavailable. Scan the QR instead."); }
83
+ });
84
+ render({ structuredContent: window.openai?.toolOutput, _meta: window.openai?.toolResponseMetadata });
85
+ setBusy(true);
86
+ void initializeBridge();
87
+ setInterval(updateExpiry, 1000);
88
+ setInterval(() => {
89
+ if (!document.hidden && current.status === "pairing" && current.expiresAt > Date.now()) void call("connection_status");
90
+ }, 5000);
91
+
92
+ if (typeof ResizeObserver !== "undefined") {
93
+ new ResizeObserver(() => {
94
+ if (bridgeReady && !legacyBridge) post({
95
+ method: "ui/notifications/size-changed",
96
+ params: { height: document.documentElement.scrollHeight },
97
+ });
98
+ }).observe(document.body);
99
+ }
@@ -0,0 +1,50 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <style>
7
+ :root{color-scheme:light dark;font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
8
+ *{box-sizing:border-box}body{margin:0;padding:16px;background:transparent;color:CanvasText}
9
+ .card{border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:20px;padding:18px;background:color-mix(in srgb,Canvas 94%,#ff7a3d 6%);box-shadow:0 8px 28px #0001}
10
+ header{display:flex;align-items:center;gap:12px;margin-bottom:14px}.logo{width:44px;height:44px;border-radius:13px;background:#ff7a3d;display:grid;place-items:center;color:#19120e;font-weight:800;font-size:19px}.title{font-size:19px;font-weight:750}.status{display:flex;align-items:center;gap:7px;color:color-mix(in srgb,CanvasText 72%,transparent)}
11
+ .dot{width:9px;height:9px;border-radius:50%;background:#ffb020}.connected .dot{background:#2ac769}.error .dot{background:#e5484d}
12
+ .body{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;align-items:center}.copy{max-width:440px}.copy h2{font-size:18px;margin:0 0 6px}.copy p{margin:0;color:color-mix(in srgb,CanvasText 70%,transparent)}
13
+ #qr{width:170px;height:170px;padding:10px;background:#fff;border-radius:15px;object-fit:contain}.hidden{display:none!important}
14
+ .actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}button{font:inherit;font-weight:650;border-radius:11px;border:1px solid color-mix(in srgb,CanvasText 20%,transparent);padding:9px 13px;background:Canvas;color:CanvasText;cursor:pointer}button.primary{background:#ff7a3d;color:#17100c;border-color:#ff7a3d}button:disabled{opacity:.55;cursor:wait}
15
+ .confirm{margin-top:12px;padding:12px;border-radius:12px;background:color-mix(in srgb,#e5484d 10%,transparent)}.confirm p{margin:0 0 10px}.small{font-size:13px}.message{min-height:20px;margin-top:10px;color:color-mix(in srgb,CanvasText 68%,transparent)}
16
+ @media(max-width:520px){.body{grid-template-columns:1fr}#qr{width:min(100%,220px);height:auto;justify-self:center}}
17
+ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-weight:600}dl{display:grid;grid-template-columns:minmax(100px,1fr) 2fr;gap:8px}dd{margin:0;overflow-wrap:anywhere}dt{opacity:.7}li{margin:8px 0}a{color:inherit}.logo svg{width:44px;height:44px}button:focus-visible,summary:focus-visible{outline:3px solid #ff7a3d;outline-offset:3px}
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <main class="card" id="card">
22
+ <header><div class="logo" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
23
+ <rect width="64" height="64" rx="10" fill="#f47f42"/>
24
+ <path d="M11 31.5 24.5 45 42 19" fill="none" stroke="#1d130e" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
25
+ <path d="M45.5 29v10M52 25v18M58.5 29v10" fill="none" stroke="#1d130e" stroke-width="5" stroke-linecap="round"/>
26
+ </svg>
27
+ </div><div><div class="title">GrantTap</div><div class="status"><span class="dot"></span><span id="status">Checking connection…</span></div></div></header>
28
+ <section class="body">
29
+ <div class="copy"><h2 id="heading">Connect this computer</h2><p id="detail">Open GrantTap on iPhone and scan the one-time code.</p><div class="actions"><button class="primary" id="connect">Connect</button><button id="refresh">Refresh status</button><button id="copy" class="hidden">Copy pairing link</button><button id="reconnect" class="hidden">Reconnect</button></div><div class="message small" id="message" role="status"></div></div>
30
+ <img id="qr" class="hidden" alt="GrantTap pairing QR" />
31
+ </section>
32
+ <p id="expiry" class="small" aria-live="polite"></p>
33
+ <details><summary>Connection details &amp; help</summary>
34
+ <dl><dt>Computer</dt><dd id="computer">—</dd><dt>MCP version</dt><dd id="version">—</dd>
35
+ <dt>Relay observed by this MCP</dt><dd id="relay">Not checked</dd>
36
+ <dt>Last encrypted message from iPhone</dt><dd id="phone">Not observed</dd></dl>
37
+ <h3>Provider readiness</h3><ul id="providers"></ul>
38
+ <p>No GrantTap account or password is required. Pair using GrantTap on iPhone → Settings → Connections.
39
+ Provider sign-in is separate.</p>
40
+ <p>Missing hooks or background sync? Run <code>npx -y granttap-mcp@0.8.9 setup</code> locally.
41
+ A saved pairing alone does not prove your phone or the background helper is online.</p>
42
+ <p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
43
+ <a href="https://granttap.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a> ·
44
+ <a href="mailto:support@granttap.com">Support</a></p>
45
+ </details>
46
+ <section id="confirm" class="confirm hidden"><p>Reconnect replaces this computer’s current pairing. Continue?</p><div class="actions"><button class="primary" id="confirm-reconnect">Reconnect</button><button id="cancel">Cancel</button></div></section>
47
+ </main>
48
+ <script type="module">{{SCRIPT}}</script>
49
+ </body>
50
+ </html>
@@ -1,7 +1,7 @@
1
1
  # MCP tools
2
2
 
3
- The Personal MCP surface is an exact allowlist: `connect`, `notify`,
4
- `ask_yes_no`, and `ask`. `relay.ts` owns one shared encrypted relay connection
3
+ The Personal MCP surface is an exact allowlist: `connection_status`, `connect`,
4
+ `reconnect`, `notify`, `ask_yes_no`, and `ask`. `relay.ts` owns one shared encrypted relay connection
5
5
  and the durable question lifecycle. Machine setup, custom relay selection, and
6
6
  pairing reset remains an explicit CLI operation. The narrower `reconnect` MCP
7
7
  tool can replace only the current pairing, is declared destructive, and requires
@@ -9,7 +9,7 @@ explicit confirmation before it creates a new one-time QR.
9
9
 
10
10
  `mesh-resource.ts` publishes the Mesh resources for that same surface, and
11
11
  `notify` may alternatively carry one bounded task-scoped Mesh event. Neither
12
- adds a fifth public tool.
12
+ adds another public tool.
13
13
 
14
14
  Both are caller-scoped. `granttap://mesh/current` returns no Project data;
15
15
  `granttap://mesh/{capability}` returns only the calling execution's Project,
@@ -26,3 +26,8 @@ call, and can never create invites, expand scope, change the relay, or reach
26
26
 
27
27
  License: this module is distributed under the GrantTap Commercial Source License
28
28
  in the repository-root `LICENSE` file.
29
+
30
+ The connection center lives in `../connection-center`. Status requests are
31
+ read-only. Saved pairing, a connected relay socket, and recent encrypted phone
32
+ activity are separate observations. Only explicit connect/reconnect requests
33
+ create codes; transfer material is carried in UI metadata and a user-facing QR.
@@ -2,18 +2,15 @@ import QRCode from "qrcode";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { z } from "zod";
5
- import { createOneTimePairing, DEFAULT_RELAY, PAIRING_CODE_TTL_MINUTES, reusablePairing } from "../../../bridge/src/pairing";
5
+ import { createOneTimePairing, DEFAULT_RELAY, PAIRING_CODE_TTL_MINUTES } from "../../../bridge/src/pairing";
6
+ import { isMachineConfigured } from "../pairing-status";
7
+ import { ConnectionState, connectionOutput } from "../connection-center/state";
6
8
  import { CONNECTION_WIDGET_URI } from "./connection-widget";
7
9
  import { resetRelay, relay } from "./relay";
8
10
 
9
- const connectionOutput = {
10
- status: z.enum(["connected", "pairing"]),
11
- relay: z.string(),
12
- expiresInMinutes: z.number().int().positive().nullable(),
13
- };
14
-
15
11
  const widgetMeta = {
16
- ui: { resourceUri: CONNECTION_WIDGET_URI },
12
+ ui: { resourceUri: CONNECTION_WIDGET_URI, visibility: ["model", "app"] },
13
+ "openai/widgetAccessible": true,
17
14
  "openai/outputTemplate": CONNECTION_WIDGET_URI,
18
15
  "openai/toolInvocation/invoking": "Opening GrantTap…",
19
16
  "openai/toolInvocation/invoked": "GrantTap is ready.",
@@ -27,6 +24,21 @@ const changes = {
27
24
  };
28
25
 
29
26
  export function registerConnectTool(server: McpServer): void {
27
+ const state = new ConnectionState();
28
+ let pendingCall: Promise<CallToolResult> | null = null;
29
+ const perform = (replace = false) => {
30
+ if (pendingCall) return pendingCall;
31
+ pendingCall = connect(state, replace).finally(() => { pendingCall = null; });
32
+ return pendingCall;
33
+ };
34
+ server.registerTool("connection_status", {
35
+ title: "GrantTap connection center",
36
+ description: "Open connection controls and inspect pairing, QR expiry, this computer, relay observations and provider readiness. Does not create or replace pairing.",
37
+ inputSchema: {},
38
+ outputSchema: connectionOutput,
39
+ annotations: { ...changes, readOnlyHint: true, idempotentHint: true },
40
+ _meta: widgetMeta,
41
+ }, async () => connectionResult(state));
30
42
  server.registerTool(
31
43
  "connect",
32
44
  {
@@ -36,7 +48,7 @@ export function registerConnectTool(server: McpServer): void {
36
48
  annotations: changes,
37
49
  _meta: widgetMeta,
38
50
  },
39
- async (): Promise<CallToolResult> => connect(),
51
+ async (): Promise<CallToolResult> => perform(),
40
52
  );
41
53
  server.registerTool(
42
54
  "reconnect",
@@ -48,85 +60,51 @@ export function registerConnectTool(server: McpServer): void {
48
60
  _meta: widgetMeta,
49
61
  },
50
62
  async ({ confirmed }): Promise<CallToolResult> => confirmed
51
- ? connect(true)
63
+ ? perform(true)
52
64
  : ({ isError: true, content: [{ type: "text", text: "Reconnect cancelled: explicit confirmation is required." }] }),
53
65
  );
54
66
  }
55
67
 
56
- async function connect(replace = false): Promise<CallToolResult> {
68
+ async function connect(state: ConnectionState, replace = false): Promise<CallToolResult> {
57
69
  try {
58
- const existing = reusablePairing(replace);
59
- if (existing) return reusedPairingResult(existing);
60
- return await oneTimePairingResult();
61
- } catch (error) {
70
+ if (!replace && isMachineConfigured()) return connectionResult(state, true);
71
+ const startedAt = Date.now();
72
+ const pairing = await createOneTimePairing(process.env.GRANTTAP_TEST_RELAY_URL ?? DEFAULT_RELAY);
73
+ const png = await QRCode.toBuffer(pairing.qrPayload, {
74
+ type: "png", width: 900, margin: 4, errorCorrectionLevel: "L",
75
+ });
76
+ state.remember({
77
+ room: pairing.machineCfg.room,
78
+ expiresAt: startedAt + PAIRING_CODE_TTL_MINUTES * 60_000,
79
+ pairingUri: pairing.qrPayload,
80
+ qrDataUrl: `data:image/png;base64,${png.toString("base64")}`,
81
+ });
82
+ resetRelay();
83
+ void relay();
84
+ return connectionResult(state, true);
85
+ } catch {
62
86
  return {
63
87
  isError: true,
64
- content: [{ type: "text", text: `GrantTap pairing could not be created: ${error instanceof Error ? error.message : String(error)}` }],
88
+ content: [{ type: "text", text: "GrantTap could not create the pairing code. Check the relay and try again. Your existing pairing is retained if the relay rejects the request." }],
65
89
  };
66
90
  }
67
91
  }
68
92
 
69
- function reusedPairingResult(pairing: { room: string; relayUrl: string }): CallToolResult {
70
- return {
71
- structuredContent: {
72
- status: "connected",
73
- relay: relayLabel(pairing.relayUrl),
74
- expiresInMinutes: null,
75
- },
76
- content: [{
77
- type: "text",
78
- text: [
79
- "GrantTap existing secure pairing reused.",
80
- `Room: ${pairing.room}`,
81
- `Relay: ${pairing.relayUrl}`,
82
- "No QR or key rotation was needed.",
83
- ].join("\n"),
84
- annotations: { audience: ["user"] },
85
- }],
86
- };
87
- }
88
-
89
- async function oneTimePairingResult(): Promise<CallToolResult> {
90
- const pairing = await createOneTimePairing(
91
- process.env.GRANTTAP_TEST_RELAY_URL ?? DEFAULT_RELAY,
92
- );
93
- resetRelay();
94
- void relay();
95
- const png = await QRCode.toBuffer(pairing.qrPayload, {
96
- type: "png", width: 900, margin: 4, errorCorrectionLevel: "L",
93
+ function connectionResult(state: ConnectionState, showQr = false): CallToolResult {
94
+ const snapshot = state.snapshot();
95
+ const { status, computer, relay, version, relayStatus } = snapshot.structuredContent;
96
+ const instructions = status === "pairing"
97
+ ? "Scan the one-time QR in GrantTap on iPhone → Settings → Connections, or copy the link from the connection card."
98
+ : status === "disconnected" ? "Choose Connect to create a one-time QR. No account or password is required."
99
+ : status === "expired" ? "The QR has expired. Reconnect with confirmation to replace the pairing and generate a new code."
100
+ : "Existing secure pairing reused. A saved pairing does not prove that the iPhone is online. Reconnect replaces it only with confirmation.";
101
+ const content: CallToolResult["content"] = [{ type: "text", text:
102
+ `GrantTap: ${status}. Computer: ${computer}. MCP ${version}. Relay: ${relay || "not configured"} (${relayStatus}).\n${instructions}`,
103
+ }];
104
+ const qr = snapshot._meta.granttap.qrDataUrl;
105
+ if (showQr && qr) content.push({
106
+ type: "image", data: qr.slice("data:image/png;base64,".length), mimeType: "image/png",
107
+ annotations: { audience: ["user"] },
97
108
  });
98
- return {
99
- structuredContent: {
100
- status: "pairing",
101
- relay: relayLabel(pairing.machineCfg.relayUrl),
102
- expiresInMinutes: PAIRING_CODE_TTL_MINUTES,
103
- },
104
- _meta: {
105
- granttap: {
106
- qrDataUrl: `data:image/png;base64,${png.toString("base64")}`,
107
- pairingUri: pairing.qrPayload,
108
- },
109
- },
110
- content: [
111
- {
112
- type: "text",
113
- text: [
114
- "Pair this Mac with GrantTap (QR optional — paste is enough):",
115
- "",
116
- "PASTE THIS in GrantTap → Settings → Connections → Paste / Add computer:",
117
- pairing.qrPayload,
118
- "",
119
- `Relay: ${pairing.httpBase}`,
120
- `One-time link — expires in ${PAIRING_CODE_TTL_MINUTES} minutes.`,
121
- "Also on Desktop: GrantTap-pair-uri.txt (when connect writes it).",
122
- ].join("\n"),
123
- annotations: { audience: ["user"] },
124
- },
125
- { type: "image", data: png.toString("base64"), mimeType: "image/png", annotations: { audience: ["user"] } },
126
- ],
127
- };
128
- }
129
-
130
- function relayLabel(relayUrl: string): string {
131
- return new URL(relayUrl).host;
109
+ return { ...snapshot, content };
132
110
  }
@@ -1,48 +1,11 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
 
3
- export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v1.html";
3
+ export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v2.html";
4
4
 
5
- const html = String.raw`<!doctype html>
6
- <html lang="en">
7
- <head>
8
- <meta charset="utf-8" />
9
- <meta name="viewport" content="width=device-width,initial-scale=1" />
10
- <style>
11
- :root{color-scheme:light dark;font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
12
- *{box-sizing:border-box}body{margin:0;padding:16px;background:transparent;color:CanvasText}
13
- .card{border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:20px;padding:18px;background:color-mix(in srgb,Canvas 94%,#ff7a3d 6%);box-shadow:0 8px 28px #0001}
14
- header{display:flex;align-items:center;gap:12px;margin-bottom:14px}.logo{width:44px;height:44px;border-radius:13px;background:#ff7a3d;display:grid;place-items:center;color:#19120e;font-weight:800;font-size:19px}.title{font-size:19px;font-weight:750}.status{display:flex;align-items:center;gap:7px;color:color-mix(in srgb,CanvasText 72%,transparent)}
15
- .dot{width:9px;height:9px;border-radius:50%;background:#ffb020}.connected .dot{background:#2ac769}.error .dot{background:#e5484d}
16
- .body{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;align-items:center}.copy{max-width:440px}.copy h2{font-size:18px;margin:0 0 6px}.copy p{margin:0;color:color-mix(in srgb,CanvasText 70%,transparent)}
17
- #qr{width:170px;height:170px;padding:10px;background:#fff;border-radius:15px;object-fit:contain}.hidden{display:none!important}
18
- .actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}button{font:inherit;font-weight:650;border-radius:11px;border:1px solid color-mix(in srgb,CanvasText 20%,transparent);padding:9px 13px;background:Canvas;color:CanvasText;cursor:pointer}button.primary{background:#ff7a3d;color:#17100c;border-color:#ff7a3d}button:disabled{opacity:.55;cursor:wait}
19
- .confirm{margin-top:12px;padding:12px;border-radius:12px;background:color-mix(in srgb,#e5484d 10%,transparent)}.confirm p{margin:0 0 10px}.small{font-size:13px}.message{min-height:20px;margin-top:10px;color:color-mix(in srgb,CanvasText 68%,transparent)}
20
- @media(max-width:520px){.body{grid-template-columns:1fr}#qr{width:min(100%,220px);height:auto;justify-self:center}}
21
- </style>
22
- </head>
23
- <body>
24
- <main class="card" id="card">
25
- <header><div class="logo" aria-hidden="true">✓▥</div><div><div class="title">GrantTap</div><div class="status"><span class="dot"></span><span id="status">Checking connection…</span></div></div></header>
26
- <section class="body">
27
- <div class="copy"><h2 id="heading">Connect this computer</h2><p id="detail">Open GrantTap on iPhone and scan the one-time code.</p><div class="actions"><button class="primary" id="connect">Connect</button><button id="copy">Copy pairing link</button><button id="reconnect">Reconnect</button></div><div class="message small" id="message" role="status"></div></div>
28
- <img id="qr" class="hidden" alt="GrantTap pairing QR" />
29
- </section>
30
- <section id="confirm" class="confirm hidden"><p>Reconnect replaces this computer’s current pairing. Continue?</p><div class="actions"><button class="primary" id="confirm-reconnect">Reconnect</button><button id="cancel">Cancel</button></div></section>
31
- </main>
32
- <script type="module">
33
- const $=id=>document.getElementById(id);let pairingUri="";let seq=0;const waiting=new Map();
34
- function metadata(value){if(!value||typeof value!=="object")return{};if(value.granttap)return value;if(value._meta)return value._meta;if(value.mcp_tool_result?._meta)return value.mcp_tool_result._meta;if(value.call_tool_result?._meta)return value.call_tool_result._meta;return{}}
35
- function output(value){return value?.structuredContent??value?.structured_content??value?.mcp_tool_result?.structuredContent??value?.call_tool_result?.structuredContent??{}}
36
- function apply(value){const state=output(value);const meta={...metadata(window.openai?.toolResponseMetadata),...metadata(value)};const gt=meta.granttap??{};if(gt.pairingUri)pairingUri=gt.pairingUri;if(gt.qrDataUrl){$("qr").src=gt.qrDataUrl;$("qr").classList.remove("hidden")}const status=state.status??"disconnected";$("card").className="card "+status;$("status").textContent=status==="connected"?"Connected":status==="pairing"?"Waiting for iPhone":"Ready to connect";$("heading").textContent=status==="connected"?"This computer is connected":"Connect this computer";$("detail").textContent=status==="connected"?("Secure pairing active"+(state.relay?(" through "+state.relay):"")+"."):"Open GrantTap on iPhone and scan the one-time code.";$("connect").classList.toggle("hidden",status!=="disconnected");$("copy").classList.toggle("hidden",!pairingUri);$("reconnect").classList.toggle("hidden",status==="disconnected");if(status==="connected")$("qr").classList.add("hidden")}
37
- function initial(){const response={structuredContent:window.openai?.toolOutput??{},_meta:window.openai?.toolResponseMetadata??{}};apply(response)}
38
- async function call(name,args={}){setBusy(true);$("message").textContent="Working…";try{let result;if(window.openai?.callTool)result=await window.openai.callTool(name,args);else result=await rpc(name,args);apply(result);$("message").textContent=result?.isError?(result.content?.[0]?.text??"GrantTap could not complete the request."):"Ready.";return result}catch(error){$("card").classList.add("error");$("message").textContent=String(error)}finally{setBusy(false)}}
39
- function rpc(name,args){const id="granttap-"+Date.now()+"-"+(++seq);window.parent.postMessage({jsonrpc:"2.0",id,method:"tools/call",params:{name,arguments:args}},"*");return new Promise((resolve,reject)=>waiting.set(id,{resolve,reject}))}
40
- function setBusy(value){document.querySelectorAll("button").forEach(button=>button.disabled=value)}
41
- window.addEventListener("message",event=>{const data=event.data;if(data?.method==="ui/notifications/tool-result")apply(data.params);const pending=waiting.get(data?.id);if(!pending)return;waiting.delete(data.id);if(data.error)pending.reject(new Error(data.error.message??"Tool call failed"));else pending.resolve(data.result)});
42
- $("connect").addEventListener("click",()=>call("connect"));$("reconnect").addEventListener("click",()=>$("confirm").classList.remove("hidden"));$("cancel").addEventListener("click",()=>$("confirm").classList.add("hidden"));$("confirm-reconnect").addEventListener("click",async()=>{$("confirm").classList.add("hidden");await call("reconnect",{confirmed:true})});$("copy").addEventListener("click",async()=>{try{await navigator.clipboard.writeText(pairingUri);$("message").textContent="Pairing link copied."}catch{$("message").textContent="Copy is unavailable. Scan the QR instead."}});initial();
43
- </script>
44
- </body>
45
- </html>`;
5
+ import { readFileSync } from "node:fs";
6
+
7
+ const asset = (name: string) => readFileSync(new URL(`../connection-center/${name}`, import.meta.url), "utf8");
8
+ const html = asset("widget.html").replace("{{SCRIPT}}", asset("bridge.js") + "\n" + asset("view.js"));
46
9
 
47
10
  export function registerConnectionWidget(server: McpServer): void {
48
11
  server.registerResource(
@@ -59,7 +22,7 @@ export function registerConnectionWidget(server: McpServer): void {
59
22
  mimeType: "text/html;profile=mcp-app",
60
23
  text: html,
61
24
  _meta: {
62
- ui: { prefersBorder: true },
25
+ ui: { prefersBorder: true, permissions: { clipboardWrite: {} }, csp: { connectDomains: [], resourceDomains: [] } },
63
26
  "openai/widgetDescription": "Connect or reconnect this computer to GrantTap with a one-time QR.",
64
27
  "openai/widgetPrefersBorder": true,
65
28
  },
@@ -12,6 +12,7 @@ const ASK_TIMEOUT_MS = Number(
12
12
  );
13
13
  let client: RelayClient | null = null;
14
14
  let monitor: SessionMonitor | null = null;
15
+ let phoneLastSeenAt: number | null = null;
15
16
 
16
17
  export type TaskInteractionScope = {
17
18
  provider: "claude" | "codex" | "cursor" | "grok";
@@ -25,6 +26,7 @@ export async function relay(): Promise<RelayClient | null> {
25
26
  try {
26
27
  if (!client) {
27
28
  client = new RelayClient(loadConfig(machineConfigPath()), { autoReconnect: true });
29
+ client.onMessage(() => { phoneLastSeenAt = Date.now(); return false; });
28
30
  monitor = startSessionMonitor(client);
29
31
  }
30
32
  await client.connect();
@@ -40,6 +42,16 @@ export function resetRelay(): void {
40
42
  monitor = null;
41
43
  client?.close();
42
44
  client = null;
45
+ phoneLastSeenAt = null;
46
+ }
47
+
48
+ /** Observe this process only; never start a connection for a status request. */
49
+ export function connectionRuntimeStatus(room?: string) {
50
+ const current = client !== null && client.room === room;
51
+ return {
52
+ relayStatus: current ? client!.isConnected ? "online" : "offline" : "unknown",
53
+ phoneLastSeenAt: current ? phoneLastSeenAt : null,
54
+ };
43
55
  }
44
56
 
45
57
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  "setup": "node bin/granttap-mcp.mjs setup",
26
26
  "status": "node bin/granttap-mcp.mjs status",
27
27
  "test": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 node --import tsx --test tests/*.test.ts",
28
- "test:coverage": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 c8 --all --extension .ts --exclude 'tests/**' --exclude 'scripts/**' --exclude 'packages/core/relay-client-types.ts' --exclude 'apps/bridge/src/capabilities/types.ts' --exclude 'apps/bridge/src/reply/types.ts' --exclude 'apps/bridge/src/mesh/runtime-dependencies.ts' --check-coverage --lines 95 --statements 95 --functions 95 --branches 85 node --import tsx --test tests/*.test.ts",
28
+ "test:coverage": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 c8 --all --extension .ts --extension .js --exclude '**/tests/**' --exclude 'scripts/**' --exclude 'packages/core/relay-client-types.ts' --exclude 'apps/bridge/src/capabilities/types.ts' --exclude 'apps/bridge/src/reply/types.ts' --exclude 'apps/bridge/src/mesh/runtime-dependencies.ts' --check-coverage --lines 95 --statements 95 --functions 95 --branches 85 node --import tsx --test tests/*.test.ts",
29
29
  "inventory": "node scripts/quality/source-inventory.mjs",
30
30
  "package:allowlist": "node scripts/quality/package-allowlist.mjs",
31
31
  "typecheck": "tsc --noEmit",
@@ -42,10 +42,12 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/express": "^5.0.3",
45
+ "@types/jsdom": "^27.0.0",
45
46
  "@types/node": "^22.10.2",
46
47
  "@types/qrcode": "^1.5.6",
47
48
  "@types/ws": "^8.5.13",
48
49
  "c8": "^10.1.3",
50
+ "jsdom": "^27.4.0",
49
51
  "typescript": "^5.7.2"
50
52
  },
51
53
  "engines": {