granttap-mcp 0.8.12 → 0.8.14
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.
|
@@ -14,11 +14,10 @@ import {
|
|
|
14
14
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
15
15
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
16
16
|
import express from "express";
|
|
17
|
-
import QRCode from "qrcode";
|
|
18
|
-
import { createOneTimePairing, DEFAULT_RELAY } from "../../bridge/src/pairing";
|
|
19
17
|
import { inspectAgentIntegrations } from "../../bridge/src/install";
|
|
20
18
|
import { createGrantTapServer, relay, resetRelay } from "./create-server";
|
|
21
19
|
import { isAllowedLoopbackOrigin } from "./oauth/loopback-origin";
|
|
20
|
+
import { installPairingRoutes } from "./oauth/pairing-view";
|
|
22
21
|
import { GrantTapOAuthProvider } from "./oauth-provider";
|
|
23
22
|
import { isMachineConfigured } from "./pairing-status";
|
|
24
23
|
|
|
@@ -67,6 +66,7 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
|
|
|
67
66
|
// authorization. The random pending id lives in the website URL fragment,
|
|
68
67
|
// never in a request to the website server.
|
|
69
68
|
app.use(["/oauth/session", "/oauth/pairing", "/oauth/decision"], (req, res, next) => {
|
|
69
|
+
if (req.originalUrl.split("?", 1)[0] === "/oauth/pairing/view") return next();
|
|
70
70
|
const origin = req.get("origin");
|
|
71
71
|
if (origin && origin !== "https://granttap.com"
|
|
72
72
|
&& !isAllowedLoopbackOrigin(origin, issuerUrl.origin)) {
|
|
@@ -105,58 +105,7 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
|
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
app
|
|
109
|
-
res.set("Cache-Control", "no-store");
|
|
110
|
-
try {
|
|
111
|
-
const origin = req.get("origin");
|
|
112
|
-
if (!isAllowedLoopbackOrigin(origin, issuerUrl.origin)) {
|
|
113
|
-
res.status(403).json({ error: "Cross-origin pairing requests are not allowed." });
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
const pendingId = String(req.body?.pending_id ?? "");
|
|
117
|
-
if (!provider.getPending(pendingId)) {
|
|
118
|
-
res.status(400).json({ error: "Authorization request expired. Start authorization again from your MCP client." });
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
if (isMachineConfigured() && req.body?.confirmed !== "true") {
|
|
122
|
-
res.json({ ok: true, alreadyPaired: true });
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
const pairing = await createOneTimePairing(
|
|
126
|
-
process.env.GRANTTAP_RELAY_URL ?? process.env.NODVOX_RELAY_URL ?? DEFAULT_RELAY,
|
|
127
|
-
{ installHooks: false },
|
|
128
|
-
);
|
|
129
|
-
resetRelay();
|
|
130
|
-
void relay();
|
|
131
|
-
const png = await QRCode.toBuffer(pairing.qrPayload, {
|
|
132
|
-
type: "png",
|
|
133
|
-
width: 480,
|
|
134
|
-
margin: 2,
|
|
135
|
-
errorCorrectionLevel: "L",
|
|
136
|
-
});
|
|
137
|
-
res.json({
|
|
138
|
-
ok: true,
|
|
139
|
-
alreadyPaired: false,
|
|
140
|
-
qrDataUrl: `data:image/png;base64,${png.toString("base64")}`,
|
|
141
|
-
manualToken: pairing.manualToken,
|
|
142
|
-
relay: pairing.httpBase,
|
|
143
|
-
providers: [
|
|
144
|
-
pairing.claude && {
|
|
145
|
-
id: "claude",
|
|
146
|
-
status: pairing.claude.status === "manual" ? "action_required" : "connected",
|
|
147
|
-
},
|
|
148
|
-
pairing.codex && {
|
|
149
|
-
id: "codex",
|
|
150
|
-
status: pairing.codex.status === "manual" ? "action_required" : "connected",
|
|
151
|
-
},
|
|
152
|
-
].filter(Boolean),
|
|
153
|
-
});
|
|
154
|
-
} catch (error) {
|
|
155
|
-
res.status(500).json({
|
|
156
|
-
error: error instanceof Error ? error.message : String(error),
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
});
|
|
108
|
+
installPairingRoutes(app, provider, issuerUrl.origin);
|
|
160
109
|
|
|
161
110
|
app.post("/oauth/decision", (req, res) => {
|
|
162
111
|
res.set("Cache-Control", "no-store");
|
|
@@ -58,7 +58,14 @@ function validateInstallation(nodeBin: string, executable: string): InstallResul
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function createPlist(nodeBin: string, executable: string, mcpUrl: URL, logsDir: string): string | Error {
|
|
61
|
-
|
|
61
|
+
let packageVersion: string;
|
|
62
|
+
try {
|
|
63
|
+
packageVersion = (JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { version: string }).version;
|
|
64
|
+
if (!/^\d+\.\d+\.\d+$/.test(packageVersion)) throw new Error("Invalid package version");
|
|
65
|
+
} catch {
|
|
66
|
+
return new Error("Installed GrantTap package version could not be read.");
|
|
67
|
+
}
|
|
68
|
+
const environment = serviceEnvironment(nodeBin, mcpUrl, packageVersion);
|
|
62
69
|
if (environment instanceof Error) return environment;
|
|
63
70
|
const logPath = join(logsDir, "http-mcp.log");
|
|
64
71
|
return [
|
|
@@ -79,12 +86,13 @@ function createPlist(nodeBin: string, executable: string, mcpUrl: URL, logsDir:
|
|
|
79
86
|
].join("\n");
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
function serviceEnvironment(nodeBin: string, mcpUrl: URL): Array<[string, string]> | Error {
|
|
89
|
+
function serviceEnvironment(nodeBin: string, mcpUrl: URL, packageVersion: string): Array<[string, string]> | Error {
|
|
83
90
|
const path = [dirname(nodeBin), join(homedir(), ".local", "bin"), "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"].join(":");
|
|
84
91
|
const values: Array<[string, string]> = [
|
|
85
92
|
["GRANTTAP_CONFIG_DIR", configDir()],
|
|
86
93
|
["GRANTTAP_MCP_HTTP_HOST", mcpUrl.hostname.replace(/^\[(.*)\]$/, "$1")],
|
|
87
94
|
["GRANTTAP_MCP_HTTP_PORT", mcpUrl.port],
|
|
95
|
+
["GRANTTAP_PACKAGE_VERSION", packageVersion],
|
|
88
96
|
["PATH", path],
|
|
89
97
|
];
|
|
90
98
|
const rawRelayUrl = process.env.GRANTTAP_RELAY_URL ?? process.env.NODVOX_RELAY_URL;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Pairing secrets are rendered by the loopback origin, never returned to site JavaScript. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { Express } from "express";
|
|
4
|
+
import QRCode from "qrcode";
|
|
5
|
+
import { createOneTimePairing, DEFAULT_RELAY } from "../../../bridge/src/pairing";
|
|
6
|
+
import { relay, resetRelay } from "../create-server";
|
|
7
|
+
import { isMachineConfigured } from "../pairing-status";
|
|
8
|
+
import type { GrantTapOAuthProvider } from "../oauth-provider";
|
|
9
|
+
import { isAllowedLoopbackOrigin } from "./loopback-origin";
|
|
10
|
+
|
|
11
|
+
const WEBSITE_ORIGIN = "https://granttap.com";
|
|
12
|
+
const VIEW_TTL_MS = 15 * 60_000;
|
|
13
|
+
type View = { qrDataUrl: string; manualToken: string; expiresAt: number };
|
|
14
|
+
|
|
15
|
+
function escapeHtml(value: string): string {
|
|
16
|
+
return value.replaceAll("&", "&").replaceAll("<", "<")
|
|
17
|
+
.replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderView(view: View, nonce: string): string {
|
|
21
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
22
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
23
|
+
<title>GrantTap phone pairing</title><style>
|
|
24
|
+
html{color-scheme:dark}body{margin:0;padding:18px;font:16px system-ui,sans-serif;color:#f6f6f6;background:#191919;text-align:center}
|
|
25
|
+
img{display:block;box-sizing:border-box;width:min(100%,280px);height:auto;margin:12px auto;padding:12px;background:#fff;border-radius:12px}
|
|
26
|
+
code{display:block;overflow-wrap:anywhere;padding:12px;background:#252525;border-radius:8px;user-select:all}
|
|
27
|
+
button{padding:10px 18px;border:0;border-radius:8px;background:#f47a45;color:#161616;font-weight:700;cursor:pointer}
|
|
28
|
+
</style></head><body><h2>Scan in GrantTap on your iPhone</h2>
|
|
29
|
+
<img src="${view.qrDataUrl}" alt="One-time GrantTap pairing QR">
|
|
30
|
+
<p>Or enter this one-time code in the app:</p><code id="token">${escapeHtml(view.manualToken)}</code>
|
|
31
|
+
<p><button id="copy" type="button">Copy code</button></p>
|
|
32
|
+
<script nonce="${nonce}">document.getElementById('copy').addEventListener('click',async()=>{
|
|
33
|
+
const button=document.getElementById('copy');try{await navigator.clipboard.writeText(document.getElementById('token').textContent);button.textContent='Copied'}catch{button.textContent='Select the code and copy it'}
|
|
34
|
+
});</script></body></html>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function installPairingRoutes(app: Express, provider: GrantTapOAuthProvider, issuerOrigin: string): void {
|
|
38
|
+
const views = new Map<string, View>();
|
|
39
|
+
app.post("/oauth/pairing", async (req, res) => {
|
|
40
|
+
res.set("Cache-Control", "no-store");
|
|
41
|
+
try {
|
|
42
|
+
const origin = req.get("origin");
|
|
43
|
+
if (!isAllowedLoopbackOrigin(origin, issuerOrigin)) {
|
|
44
|
+
res.status(403).json({ error: "Cross-origin pairing requests are not allowed." });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const pendingId = String(req.body?.pending_id ?? "");
|
|
48
|
+
if (!provider.getPending(pendingId)) {
|
|
49
|
+
res.status(400).json({ error: "Authorization request expired. Start authorization again from your MCP client." });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (isMachineConfigured() && req.body?.confirmed !== "true") {
|
|
53
|
+
res.json({ ok: true, alreadyPaired: true });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const pairing = await createOneTimePairing(
|
|
57
|
+
process.env.GRANTTAP_RELAY_URL ?? process.env.NODVOX_RELAY_URL ?? DEFAULT_RELAY,
|
|
58
|
+
{ installHooks: false },
|
|
59
|
+
);
|
|
60
|
+
resetRelay();
|
|
61
|
+
void relay();
|
|
62
|
+
const png = await QRCode.toBuffer(pairing.qrPayload, {
|
|
63
|
+
type: "png", width: 480, margin: 2, errorCorrectionLevel: "L",
|
|
64
|
+
});
|
|
65
|
+
const qrDataUrl = `data:image/png;base64,${png.toString("base64")}`;
|
|
66
|
+
if (origin === WEBSITE_ORIGIN) {
|
|
67
|
+
for (const [id, view] of views) if (view.expiresAt <= Date.now()) views.delete(id);
|
|
68
|
+
if (views.size >= 16) views.delete(views.keys().next().value!);
|
|
69
|
+
const viewId = randomUUID();
|
|
70
|
+
views.set(viewId, { qrDataUrl, manualToken: pairing.manualToken, expiresAt: Date.now() + VIEW_TTL_MS });
|
|
71
|
+
res.json({ ok: true, alreadyPaired: false, viewId });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
res.json({
|
|
75
|
+
ok: true, alreadyPaired: false, qrDataUrl, manualToken: pairing.manualToken,
|
|
76
|
+
relay: pairing.httpBase,
|
|
77
|
+
providers: [
|
|
78
|
+
pairing.claude && { id: "claude", status: pairing.claude.status === "manual" ? "action_required" : "connected" },
|
|
79
|
+
pairing.codex && { id: "codex", status: pairing.codex.status === "manual" ? "action_required" : "connected" },
|
|
80
|
+
].filter(Boolean),
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.get("/oauth/pairing/view", (req, res) => {
|
|
88
|
+
const view = views.get(String(req.query.view_id ?? ""));
|
|
89
|
+
if (!view || view.expiresAt <= Date.now()) {
|
|
90
|
+
res.status(404).json({ error: "Pairing code expired. Start again from your coding app." });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const nonce = randomUUID().replaceAll("-", "");
|
|
94
|
+
res.set({
|
|
95
|
+
"Cache-Control": "no-store",
|
|
96
|
+
"Referrer-Policy": "no-referrer",
|
|
97
|
+
"X-Content-Type-Options": "nosniff",
|
|
98
|
+
"Content-Security-Policy": `default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'nonce-${nonce}'; frame-ancestors ${WEBSITE_ORIGIN}; base-uri 'none'; form-action 'none'`,
|
|
99
|
+
});
|
|
100
|
+
res.type("html").send(renderView(view, nonce));
|
|
101
|
+
});
|
|
102
|
+
}
|