granttap-mcp 0.8.9 → 0.8.11

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.
@@ -62,18 +62,21 @@ async function main(): Promise<void> {
62
62
  engineFlag >= 0 ? process.argv[engineFlag + 1] : undefined,
63
63
  );
64
64
  const helper = installMonitorHelper();
65
- let cursorService: InstallResult | null = null;
65
+ let httpService: InstallResult | null = null;
66
66
  let cursorConfig: InstallResult | null = null;
67
- if (before.cursor.installed) {
68
- cursorService = installHttpMcpService();
69
- if (cursorService.status !== "manual") cursorConfig = installCursorHttpConfig();
67
+ const mcpClientInstalled = before.agents.some((item) => item.installed);
68
+ if (before.cursor.installed || mcpClientInstalled) {
69
+ httpService = installHttpMcpService();
70
+ if (before.cursor.installed && httpService.status !== "manual") {
71
+ cursorConfig = installCursorHttpConfig();
72
+ }
70
73
  }
71
74
  const pairingResult = await pairIfNeeded();
72
75
  const paired = isMachineConfigured();
73
76
  const installed = new Set(before.agents.filter((item) => item.installed).map((item) => item.agent));
74
77
  const cursorReady = cursorHook.status !== "manual"
75
78
  && (!before.cursor.installed
76
- || (cursorService?.status !== "manual" && cursorConfig?.status !== "manual"));
79
+ || (httpService?.status !== "manual" && cursorConfig?.status !== "manual"));
77
80
 
78
81
  process.stdout.write([
79
82
  "GrantTap",
@@ -81,18 +84,23 @@ async function main(): Promise<void> {
81
84
  `Phone pairing ${paired ? "Ready" : pairingResult === "failed" ? "Relay unavailable" : "Needs connection"}`,
82
85
  `Background helper ${state(helper)}`,
83
86
  "",
84
- `Claude Code ${installed.has("claude") ? state(claudeHook) : "Not installed"}`,
87
+ `Claude Code ${installed.has("claude")
88
+ ? claudeHook.status === "manual" || httpService?.status === "manual"
89
+ ? "Needs attention" : "Authorize in Claude Code" : "Not installed"}`,
85
90
  `Codex ${installed.has("codex")
86
- ? codexHook.status === "manual" ? "Needs attention" : "Needs hook trust"
91
+ ? codexHook.status === "manual" || httpService?.status === "manual"
92
+ ? "Needs attention" : !paired ? "Needs connection" : "Authorize in Codex; review hooks"
87
93
  : "Not installed"}`,
88
94
  `Cursor ${before.cursor.installed ? `Beta · ${cursorReady ? "Authorize in Cursor" : "Needs repair"}` : "Not installed"}`,
89
- `Grok Build ${installed.has("grok") ? "Ready" : "Not installed"}`,
95
+ `Grok Build ${installed.has("grok")
96
+ ? httpService?.status === "manual" ? "Needs attention" : "Authorize in Grok Build"
97
+ : "Not installed"}`,
90
98
  // Governance is edited on the phone but cannot report until an engine is
91
99
  // declared here, so say plainly which of the two is missing.
92
100
  `Project Governance ${engine ? "Ready" : "No engine found"}`,
93
101
  "",
94
102
  paired && installed.has("codex")
95
- ? `Next: ${CODEX_TRUST_INSTRUCTION}`
103
+ ? `Next: authorize GrantTap in Codex, then ${CODEX_TRUST_INSTRUCTION}`
96
104
  : paired && before.cursor.installed
97
105
  ? "Next: open Cursor Settings → MCP → GrantTap → Authorize."
98
106
  : !paired
@@ -72,7 +72,7 @@ async function authorize(): Promise<void> {
72
72
  `[granttap-mcp] Persistent local OAuth is healthy at ${mcpUrl}`,
73
73
  `[granttap-mcp] LaunchAgent: ${service.status} (${service.detail})`,
74
74
  "[granttap-mcp] Open Cursor Settings → MCP → GrantTap → Authorize.",
75
- "[granttap-mcp] If this Mac is not paired, the consent page shows a QR and manual token.",
75
+ "[granttap-mcp] If this Mac is not paired, granttap.com/connect shows a QR and manual token.",
76
76
  "",
77
77
  ].join("\n"),
78
78
  );
@@ -37,7 +37,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
37
37
  <h3>Provider readiness</h3><ul id="providers"></ul>
38
38
  <p>No GrantTap account or password is required. Pair using GrantTap on iPhone → Settings → Connections.
39
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.
40
+ <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.11</code> and <code>granttap setup</code> locally.
41
41
  A saved pairing alone does not prove your phone or the background helper is online.</p>
42
42
  <p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
43
43
  <a href="https://granttap.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a> ·
@@ -47,4 +47,4 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
47
47
  </main>
48
48
  <script type="module">{{SCRIPT}}</script>
49
49
  </body>
50
- </html>
50
+ </html>
@@ -1,7 +1,7 @@
1
1
  /**
2
- * GrantTap MCP over Streamable HTTP + loopback OAuth.
2
+ * GrantTap MCP over Streamable HTTP + loopback OAuth with website consent.
3
3
  *
4
- * This is what Cursor Settings needs for the Authorize button (stdio cannot show it).
4
+ * Codex, Claude Code, Cursor and Grok Build can discover this OAuth endpoint.
5
5
  * Bind is loopback-only. Pairing keys remain in ~/.granttap.
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
@@ -16,6 +16,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
16
16
  import express from "express";
17
17
  import QRCode from "qrcode";
18
18
  import { createOneTimePairing, DEFAULT_RELAY } from "../../bridge/src/pairing";
19
+ import { inspectAgentIntegrations } from "../../bridge/src/install";
19
20
  import { createGrantTapServer, relay, resetRelay } from "./create-server";
20
21
  import { isAllowedLoopbackOrigin } from "./oauth/loopback-origin";
21
22
  import { GrantTapOAuthProvider } from "./oauth-provider";
@@ -62,6 +63,48 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
62
63
  serviceDocumentationUrl: new URL("https://granttap.com"),
63
64
  }));
64
65
 
66
+ // The public website is the only browser origin allowed to read a pending
67
+ // authorization. The random pending id lives in the website URL fragment,
68
+ // never in a request to the website server.
69
+ app.use(["/oauth/session", "/oauth/pairing", "/oauth/decision"], (req, res, next) => {
70
+ const origin = req.get("origin");
71
+ if (origin && origin !== "https://granttap.com"
72
+ && !isAllowedLoopbackOrigin(origin, issuerUrl.origin)) {
73
+ res.status(403).json({ error: "Origin is not allowed." });
74
+ return;
75
+ }
76
+ if (origin === "https://granttap.com") {
77
+ res.set({
78
+ "Access-Control-Allow-Origin": origin,
79
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
80
+ "Access-Control-Allow-Headers": "Content-Type",
81
+ "Access-Control-Allow-Private-Network": "true",
82
+ "Vary": "Origin, Access-Control-Request-Private-Network",
83
+ });
84
+ }
85
+ if (req.method === "OPTIONS") {
86
+ res.status(204).end();
87
+ return;
88
+ }
89
+ next();
90
+ });
91
+
92
+ app.get("/oauth/session", (req, res) => {
93
+ res.set("Cache-Control", "no-store");
94
+ const pending = provider.getPending(String(req.query.pending_id ?? ""));
95
+ if (!pending) {
96
+ res.status(404).json({ error: "This connection request expired. Start again in your coding app." });
97
+ return;
98
+ }
99
+ res.json({
100
+ clientName: pending.client.client_name?.trim().slice(0, 80) || "Coding app",
101
+ paired: isMachineConfigured(),
102
+ providers: inspectAgentIntegrations()
103
+ .filter((item) => ["codex", "claude", "cursor"].includes(item.agent))
104
+ .map((item) => ({ id: item.agent, installed: item.installed, ready: item.hookConfigured })),
105
+ });
106
+ });
107
+
65
108
  app.post("/oauth/pairing", async (req, res) => {
66
109
  res.set("Cache-Control", "no-store");
67
110
  try {
@@ -72,10 +115,10 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
72
115
  }
73
116
  const pendingId = String(req.body?.pending_id ?? "");
74
117
  if (!provider.getPending(pendingId)) {
75
- res.status(400).json({ error: "Authorization request expired. Start Authorize again from Cursor Settings." });
118
+ res.status(400).json({ error: "Authorization request expired. Start authorization again from your MCP client." });
76
119
  return;
77
120
  }
78
- if (isMachineConfigured()) {
121
+ if (isMachineConfigured() && req.body?.confirmed !== "true") {
79
122
  res.json({ ok: true, alreadyPaired: true });
80
123
  return;
81
124
  }
@@ -115,6 +158,28 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
115
158
  }
116
159
  });
117
160
 
161
+ app.post("/oauth/decision", (req, res) => {
162
+ res.set("Cache-Control", "no-store");
163
+ if (req.get("origin") !== "https://granttap.com") {
164
+ res.status(403).json({ error: "Origin is not allowed." });
165
+ return;
166
+ }
167
+ try {
168
+ const pendingId = String(req.body?.pending_id ?? "");
169
+ const decision = String(req.body?.decision ?? "");
170
+ if (decision !== "approve" && decision !== "deny") {
171
+ res.status(400).json({ error: "Choose Approve or Deny." });
172
+ return;
173
+ }
174
+ const { redirectUrl } = provider.completeConsent(pendingId, decision === "approve");
175
+ res.json({ redirectUrl });
176
+ } catch (error) {
177
+ res.status(400).json({
178
+ error: error instanceof Error ? error.message : String(error),
179
+ });
180
+ }
181
+ });
182
+
118
183
  app.post("/consent", (req, res) => {
119
184
  res.set("Cache-Control", "no-store");
120
185
  try {
@@ -27,7 +27,7 @@ export function installHttpMcpService(options: { forceReload?: boolean } = {}):
27
27
  }
28
28
 
29
29
  function macosOnly(): InstallResult {
30
- return { status: "manual", detail: "persistent Cursor OAuth currently requires macOS" };
30
+ return { status: "manual", detail: "Persistent GrantTap MCP authorization currently requires macOS" };
31
31
  }
32
32
 
33
33
  function configuredMcpUrl(): URL | Error {
@@ -42,13 +42,13 @@ function safeNodeBin(): string | Error {
42
42
  const nodeBin = resolveMonitorNodeBin();
43
43
  return nodeBin && !isCursorHelperNode(nodeBin)
44
44
  ? nodeBin
45
- : new Error("No safe absolute Node binary is available; refusing Cursor's short-lived helper node.");
45
+ : new Error("No safe absolute Node binary is available for the persistent GrantTap service.");
46
46
  }
47
47
 
48
48
  function validateInstallation(nodeBin: string, executable: string): InstallResult | undefined {
49
49
  if (!existsSync(executable)) return { status: "manual", detail: `Installed launcher is missing at ${executable}` };
50
50
  if (packageRoot.includes("/.npm/_npx/")) {
51
- return { status: "manual", detail: "Authorize requires a stable granttap-mcp install; npm's temporary _npx cache is not durable." };
51
+ return { status: "manual", detail: "Authorization requires a stable granttap-mcp install; npm's temporary _npx cache is not durable." };
52
52
  }
53
53
  const version = spawnSync(nodeBin, ["--version"], { encoding: "utf8" });
54
54
  const major = Number(/^v(\d+)/.exec(version.stdout.trim())?.[1]);
@@ -1,5 +1,5 @@
1
1
  # OAuth
2
2
 
3
- This module owns local Cursor OAuth client persistence, consent rendering, and token issuance. It never replaces the encrypted GrantTap pairing stored by the bridge.
3
+ This module owns local MCP OAuth client persistence and token issuance. The OAuth authorization endpoint immediately redirects to `https://granttap.com/connect`; the website reads a short-lived request through the loopback helper, while pairing keys remain on the computer and phone. The local service never renders a user-facing consent page.
4
4
 
5
5
  License: this module is distributed under the GrantTap Commercial Source License in the repository-root `LICENSE` file.
@@ -9,6 +9,7 @@ export function isAllowedLoopbackOrigin(
9
9
  ): boolean {
10
10
  if (!origin) return true;
11
11
  if (origin === issuerOrigin) return true;
12
+ if (origin === "https://granttap.com") return true;
12
13
  const lower = origin.toLowerCase();
13
14
  return lower.startsWith("vscode-webview:")
14
15
  || lower.startsWith("cursor:")
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Loopback OAuth 2.1 provider for Cursor Settings → Authorize.
2
+ * Loopback OAuth 2.1 provider for Codex and Cursor MCP authorization.
3
3
  *
4
- * Authorize means: confirm this Mac's GrantTap pairing for Cursor (issue a
4
+ * Authorize means: confirm this Mac's GrantTap pairing for a client (issue a
5
5
  * bearer token). E2EE keys stay in ~/.granttap — OAuth does not replace pair.
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
@@ -22,7 +22,6 @@ import type {
22
22
  OAuthTokens,
23
23
  } from "@modelcontextprotocol/sdk/shared/auth.js";
24
24
  import { isMachineConfigured } from "./pairing-status";
25
- import { consentHtml } from "./oauth/consent-page";
26
25
  import { GrantTapClientsStore, loadOAuthStore, saveOAuthStore } from "./oauth/store";
27
26
 
28
27
  type PendingAuth = {
@@ -48,7 +47,7 @@ export class GrantTapOAuthProvider implements OAuthServerProvider {
48
47
 
49
48
  constructor(private readonly expectedResource?: string) {}
50
49
 
51
- /** Begin auth: stash params and return a pending id for the consent page. */
50
+ /** Begin auth: stash params; the website reads only the opaque request id. */
52
51
  createPending(client: OAuthClientInformationFull, params: AuthorizationParams): string {
53
52
  this.gcPending();
54
53
  const id = randomUUID();
@@ -86,28 +85,21 @@ export class GrantTapOAuthProvider implements OAuthServerProvider {
86
85
  resource: this.expectedResource ? new URL(this.expectedResource) : params.resource,
87
86
  };
88
87
  const pendingId = this.createPending(client, normalizedParams);
89
- const paired = isMachineConfigured();
90
88
  res.set({
91
89
  "Cache-Control": "no-store",
92
- "Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
93
90
  "Referrer-Policy": "no-referrer",
94
- "X-Frame-Options": "DENY",
95
91
  "X-Content-Type-Options": "nosniff",
96
92
  });
97
- res.status(200).type("html").send(consentHtml({
98
- pendingId,
99
- paired,
100
- clientName: client.client_name?.trim() || "Unknown local MCP client",
101
- redirectUri: normalizedParams.redirectUri,
102
- scopes: normalizedParams.scopes ?? [],
103
- resource: normalizedParams.resource?.href ?? "local GrantTap MCP",
104
- }));
93
+ const website = new URL("https://granttap.com/connect");
94
+ const resource = new URL(this.expectedResource ?? normalizedParams.resource!.href);
95
+ website.hash = new URLSearchParams({ request: pendingId, port: resource.port }).toString();
96
+ res.redirect(302, website.href);
105
97
  }
106
98
 
107
- /** Complete consent: issue code and redirect to Cursor. */
99
+ /** Complete consent: issue code and redirect to the requesting MCP client. */
108
100
  completeConsent(pendingId: string, approve: boolean): { redirectUrl: string } {
109
101
  const pending = this.getPending(pendingId);
110
- if (!pending) throw new Error("Authorization request expired. Start Authorize again from Cursor Settings.");
102
+ if (!pending) throw new Error("Authorization request expired. Start authorization again from your MCP client.");
111
103
 
112
104
  const target = new URL(pending.params.redirectUri);
113
105
  if (!approve) {
@@ -21,16 +21,16 @@ The commands have separate jobs:
21
21
  1. `granttap setup` detects Cursor, installs and verifies the persistent loopback OAuth/MCP
22
22
  service, then writes only GrantTap's entry in `~/.cursor/mcp.json`.
23
23
  2. `granttap connect` displays a one-time QR and a short manual code for pairing
24
- the iPhone or iPad app. If Cursor's consent page already completed pairing,
24
+ the iPhone or iPad app. If the website already completed pairing,
25
25
  this command is not needed again.
26
26
  3. `granttap setup` installs the Cursor, Claude Code, and Codex policy hooks and
27
27
  the background task-sync helper. OAuth remains a separate authorization.
28
28
  4. `granttap status` performs a read-only readiness check.
29
29
 
30
30
  After `granttap setup`, open **Cursor Settings → MCP → GrantTap** and choose
31
- **Authorize**. The local consent page confirms access to this Mac's GrantTap
32
- pairing. If the Mac is not paired, it offers the same one-time QR and manual-code
33
- fallback as the CLI.
31
+ **Authorize**. The browser opens granttap.com/connect to authorize Cursor.
32
+ If the Mac is not paired, the website shows a one-time QR and manual-code
33
+ fallback; a saved pairing is reused unless you explicitly confirm reconnect.
34
34
 
35
35
  The OAuth service listens only at `http://127.0.0.1:17342/mcp`. Cursor cannot
36
36
  show **Authorize** for a stdio (`command`/`args`) MCP entry, so do not replace the
@@ -46,8 +46,8 @@ granttap connect
46
46
  ```
47
47
 
48
48
  Show the resulting one-time QR in chat. Preserve the short manual code as a
49
- fallback when the camera or QR rendering is unavailable. The local OAuth
50
- consent page can offer the same pairing choices when Authorize starts first.
49
+ fallback when the camera or QR rendering is unavailable. When Authorize starts
50
+ first, `granttap.com/connect` offers the same pairing choices.
51
51
 
52
52
  ### 3. Install policy hooks
53
53
 
@@ -9,7 +9,7 @@ granttap setup
9
9
  When Cursor is installed, setup preserves unrelated MCP entries, installs the
10
10
  GrantTap shell and MCP policy hooks, configures the loopback HTTP MCP endpoint,
11
11
  and installs its persistent per-user service. Then open **Cursor Settings → MCP
12
- → GrantTap → Authorize** to complete Cursor's browser consent.
12
+ → GrantTap → Authorize** to complete authorization on granttap.com.
13
13
 
14
14
  The service binds only to loopback. Its OAuth token does not replace GrantTap's
15
15
  end-to-end encrypted computer pairing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,61 +0,0 @@
1
- import { inspectAgentIntegrations } from "../../../bridge/src/install";
2
-
3
- type ConsentOptions = {
4
- pendingId: string;
5
- paired: boolean;
6
- clientName: string;
7
- redirectUri: string;
8
- scopes: string[];
9
- resource: string;
10
- };
11
-
12
- type IntegrationRow = {
13
- id: "claude" | "codex";
14
- label: string;
15
- status: "connected" | "action_required" | "not_configured";
16
- detail: string;
17
- };
18
-
19
- function integrationRows(): IntegrationRow[] {
20
- return inspectAgentIntegrations()
21
- .filter((integration): integration is typeof integration & { agent: "claude" | "codex" } =>
22
- integration.agent === "claude" || integration.agent === "codex")
23
- .map((integration) => ({
24
- id: integration.agent,
25
- label: integration.agent === "claude" ? "Claude Code" : "Codex",
26
- status: integration.agent === "codex" && integration.hookConfigured
27
- ? "action_required"
28
- : integration.hookConfigured ? "connected" : integration.installed ? "action_required" : "not_configured",
29
- detail: integration.agent === "codex" && integration.hookConfigured
30
- ? "Both hooks installed. Open /hooks, review and trust both GrantTap hooks, then restart Codex."
31
- : integration.hookConfigured ? "Approval hook installed."
32
- : integration.installed ? "Run granttap setup to install the approval hook."
33
- : "Agent was not found on this Mac.",
34
- }));
35
- }
36
-
37
- function escapeHtml(value: string): string {
38
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
39
- .replaceAll('"', "&quot;").replaceAll("'", "&#39;");
40
- }
41
-
42
- function statusCards(paired: boolean): string {
43
- const rows = [
44
- { id: "phone", label: "iPhone / Apple Watch", status: "action_required", detail: paired ? "Local E2EE keys exist; live phone reachability is not verified on this page." : "Scan the QR or paste the manual token." },
45
- { id: "cursor", label: "Cursor", status: "action_required", detail: "Review this local client and approve access below." },
46
- ...integrationRows(),
47
- ];
48
- return rows.map((row) => {
49
- const label = row.status === "connected" ? "Connected" : row.status === "action_required" ? "Action required" : "Not configured";
50
- return `<div class="provider"><div><strong>${escapeHtml(row.label)}</strong><small>${escapeHtml(row.detail)}</small></div><span id="status-${row.id}" class="chip ${row.status}">${label}</span></div>`;
51
- }).join("");
52
- }
53
-
54
- export function consentHtml(opts: ConsentOptions): string {
55
- const { pendingId, paired, clientName, redirectUri, scopes, resource } = opts;
56
- return `<!DOCTYPE html>
57
- <html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Authorize GrantTap</title>
58
- <style>:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f1419;color:#e8eef4}main{width:min(520px,92vw);padding:28px;border-radius:16px;background:#1a222c;box-shadow:0 16px 48px #0008}h1{font-size:1.25rem;margin:0 0 8px}p{margin:0 0 16px;line-height:1.45;color:#b7c4d2;font-size:.95rem}#qr{display:none;margin:0 auto 16px;width:240px;height:240px;background:#fff;border-radius:12px;padding:10px;box-sizing:border-box}#qr img{width:100%;height:100%;object-fit:contain}.client{padding:12px;border:1px solid #334151;border-radius:10px;background:#121920;margin-bottom:16px}.client strong,.client code{display:block;overflow-wrap:anywhere}.client code{margin-top:4px;color:#9fb0c0;font-size:.75rem}.providers{display:grid;gap:8px;margin:0 0 18px}.provider{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:10px 12px;border:1px solid #334151;border-radius:10px}.provider strong,.provider small{display:block}.provider small{margin-top:3px;color:#8fa3b8}.chip{flex:none;padding:4px 7px;border:1px solid;border-radius:999px;font-size:.65rem;font-weight:750;text-transform:uppercase}.connected{color:#68d39c;border-color:#68d39c66}.action_required{color:#f0bb7b;border-color:#f0bb7b66}.not_configured{color:#8fa3b8;border-color:#8fa3b866}#manual{display:none;margin:0 0 16px;padding:12px;border:1px solid #334151;border-radius:10px}#manual code{display:block;margin:7px 0;padding:8px;overflow-wrap:anywhere;background:#0c1117;border-radius:7px;user-select:all}.row{display:flex;gap:10px}button{flex:1;border:0;border-radius:10px;padding:12px 14px;font-weight:600;cursor:pointer}.approve{background:#3d8bfd;color:#fff}.deny{background:#2a3440;color:#d7e0ea}.status{font-size:.85rem;color:#8fa3b8;min-height:1.2em}</style></head>
59
- <body><main><h1>Authorize ${escapeHtml(clientName)} → GrantTap</h1><p>Grant this local MCP client access to GrantTap tools on this Mac. E2EE keys stay local in <code>~/.granttap</code>.</p><div class="client"><strong>Requesting client: ${escapeHtml(clientName)}</strong><code>Permission: ${escapeHtml(scopes.join(" "))}</code><code>Resource: ${escapeHtml(resource)}</code><code>Redirect: ${escapeHtml(redirectUri)}</code></div><div class="providers">${statusCards(paired)}</div><div id="qr"></div><div id="manual"><strong>Camera unavailable?</strong><span> Paste this one-time token in the GrantTap app:</span><code id="manual-code"></code><button type="button" id="copy-token">Copy token</button></div><p class="status" id="status">${paired ? "Local pairing keys found; phone reachability is not verified here." : "Creating a pairing QR…"}</p><p>Approve grants this MCP client local tool access. It does not prove that the phone has scanned the pairing code.</p><form id="form" method="POST" action="/consent" class="row"><input type="hidden" name="pending_id" value="${pendingId}" /><button class="deny" type="submit" name="decision" value="deny">Deny</button><button class="approve" type="submit" name="decision" value="approve" id="approve" ${paired ? "" : "disabled"}>Approve</button></form></main>
60
- <script>const paired=${paired ? "true" : "false"};const status=document.getElementById("status");const approve=document.getElementById("approve");const qrBox=document.getElementById("qr");const manualBox=document.getElementById("manual");const manualCode=document.getElementById("manual-code");const copyToken=document.getElementById("copy-token");copyToken.addEventListener("click",async()=>{try{await navigator.clipboard.writeText(manualCode.textContent||"");status.textContent="Manual token copied. It expires after 15 minutes."}catch{status.textContent="Select the manual token and copy it."}});async function ensurePairing(){if(paired)return;const body=new URLSearchParams({pending_id:${JSON.stringify(pendingId)}});const res=await fetch("/oauth/pairing",{method:"POST",body});const data=await res.json();if(!res.ok){status.textContent=data.error||"Pairing failed";return}if(data.alreadyPaired){status.textContent="Local pairing keys found. Review the client and Approve; verify the phone with a live request.";approve.disabled=false;return}qrBox.style.display="block";qrBox.innerHTML='<img alt="GrantTap pairing QR" src="'+data.qrDataUrl+'" />';manualCode.textContent=data.manualToken;manualBox.style.display="block";for(const provider of data.providers||[]){const chip=document.getElementById("status-"+provider.id);if(!chip)continue;chip.textContent=provider.status==="connected"?"Connected":"Action required";chip.className="chip "+provider.status}status.textContent="Scan with GrantTap on iPhone, then Approve.";approve.disabled=false}ensurePairing().catch((err)=>{status.textContent=String(err)});</script></body></html>`;
61
- }