granttap-mcp 0.1.0

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,133 @@
1
+ /**
2
+ * Auto-registration of the agent hooks — the part that used to be "copy this
3
+ * snippet into your config yourself".
4
+ *
5
+ * Claude Code ~/.claude/settings.json hooks.PreToolUse
6
+ * Codex ~/.codex/config.toml [features] hooks + [[hooks.PermissionRequest]]
7
+ *
8
+ * Rules: back the file up once before first touching it, never register twice
9
+ * (any existing "granttap" hook counts), and if a TOML merge would be ambiguous,
10
+ * say so instead of corrupting the file.
11
+ */
12
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
18
+
19
+ /** The stable command agents will call. */
20
+ export function hookCommand(agent: "claude" | "codex"): string {
21
+ return `node "${join(repoRoot, "bin", "granttap-mcp.mjs")}" hook ${agent}`;
22
+ }
23
+
24
+ export type InstallResult = { status: "installed" | "already" | "manual"; detail: string };
25
+
26
+ function backupOnce(path: string): void {
27
+ const bak = path + ".bak-granttap";
28
+ if (existsSync(path) && !existsSync(bak)) copyFileSync(path, bak);
29
+ }
30
+
31
+ // ---------------------------------------------------------------- Claude Code
32
+
33
+ export function installClaudeHook(): InstallResult {
34
+ const dir =
35
+ process.env.GRANTTAP_CLAUDE_DIR ??
36
+ process.env.NODVOX_CLAUDE_DIR ??
37
+ join(homedir(), ".claude");
38
+ const path = join(dir, "settings.json");
39
+ mkdirSync(dir, { recursive: true });
40
+
41
+ let settings: any = {};
42
+ if (existsSync(path)) {
43
+ try {
44
+ settings = JSON.parse(readFileSync(path, "utf8"));
45
+ } catch {
46
+ return { status: "manual", detail: `${path} — не парсится как JSON, не трогаю. Добавь hook вручную.` };
47
+ }
48
+ }
49
+
50
+ const entries: any[] = (((settings.hooks ??= {}).PreToolUse ??= []) as any[]);
51
+ const hooks = entries.flatMap((entry) => entry?.hooks ?? []);
52
+ const present = hooks.some(
53
+ (hook: any) => typeof hook?.command === "string" && hook.command.includes("granttap"),
54
+ );
55
+ if (present) return { status: "already", detail: path };
56
+
57
+ const legacy = hooks.find(
58
+ (hook: any) =>
59
+ typeof hook?.command === "string" &&
60
+ (hook.command.includes("bin/nodvox.mjs") || /\bnpx\s+(?:-y\s+)?nodvox\b/.test(hook.command)),
61
+ );
62
+ if (legacy) {
63
+ backupOnce(path);
64
+ legacy.command = hookCommand("claude");
65
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
66
+ return { status: "installed", detail: `${path}, обновлён Nodvox → GrantTap` };
67
+ }
68
+
69
+ backupOnce(path);
70
+ entries.push({
71
+ matcher: "Bash|Edit|Write|MultiEdit|NotebookEdit|mcp__.*",
72
+ hooks: [{ type: "command", command: hookCommand("claude"), timeout: 120 }],
73
+ });
74
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
75
+ return { status: "installed", detail: path };
76
+ }
77
+
78
+ // --------------------------------------------------------------------- Codex
79
+
80
+ export function installCodexHook(): InstallResult {
81
+ const dir =
82
+ process.env.GRANTTAP_CODEX_DIR ??
83
+ process.env.NODVOX_CODEX_DIR ??
84
+ join(homedir(), ".codex");
85
+ const path = join(dir, "config.toml");
86
+ mkdirSync(dir, { recursive: true });
87
+
88
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
89
+ if (existing.includes("granttap")) return { status: "already", detail: path };
90
+ if (existing.includes("bin/nodvox.mjs") || /\bnpx\s+(?:-y\s+)?nodvox\b/.test(existing)) {
91
+ const updated = existing
92
+ .replace(
93
+ /^command\s*=\s*'[^']*(?:bin\/nodvox\.mjs|\bnpx\s+(?:-y\s+)?nodvox\b)[^']*'$/gm,
94
+ `command = '${hookCommand("codex")}'`,
95
+ )
96
+ .replace(/^# nodvox —/gm, "# GrantTap —");
97
+ backupOnce(path);
98
+ writeFileSync(path, updated);
99
+ return { status: "installed", detail: `${path}, обновлён Nodvox → GrantTap` };
100
+ }
101
+
102
+ const hookBlock = [
103
+ "",
104
+ "# granttap — approvals from your phone/watch",
105
+ "[[hooks.PermissionRequest]]",
106
+ 'matcher = ".*"',
107
+ "[[hooks.PermissionRequest.hooks]]",
108
+ 'type = "command"',
109
+ `command = '${hookCommand("codex")}'`,
110
+ "timeout = 120",
111
+ "",
112
+ ].join("\n");
113
+
114
+ const hasFeatures = /^\s*\[features\]/m.test(existing);
115
+ const hasHooksFlag = /^\s*hooks\s*=\s*true/m.test(existing);
116
+
117
+ backupOnce(path);
118
+ if (!hasFeatures) {
119
+ writeFileSync(path, existing + "\n[features]\nhooks = true\n" + hookBlock);
120
+ return { status: "installed", detail: path };
121
+ }
122
+ if (hasHooksFlag) {
123
+ writeFileSync(path, existing + hookBlock);
124
+ return { status: "installed", detail: path };
125
+ }
126
+ // [features] exists without hooks=true — appending a second [features] table
127
+ // would be invalid TOML, so append the hook and ask for the one-line edit.
128
+ writeFileSync(path, existing + hookBlock);
129
+ return {
130
+ status: "manual",
131
+ detail: `${path}: hook добавлен, но в существующей секции [features] нужно вручную дописать «hooks = true».`,
132
+ };
133
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * GrantTap MCP server — the easy front door.
3
+ *
4
+ * Adding this to any MCP client (`claude mcp add granttap …`) gives the agent a
5
+ * VOLUNTARY channel to your wrist, complementing the hook's involuntary gating:
6
+ *
7
+ * ask — ask you a free-text question, wait for the spoken/typed answer
8
+ * ask_yes_no — ask a yes/no question, get Да/Нет from the watch
9
+ * notify — push a status/message to the phone (fire-and-forget)
10
+ * setup — register the Claude/Codex approval hooks on this machine
11
+ *
12
+ * All traffic is E2E-encrypted through the same relay; the MCP server never sees
13
+ * plaintext leave the machine unsealed.
14
+ */
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { z } from "zod";
18
+ import { RelayClient } from "../../../packages/core/relay-client";
19
+ import { loadConfig, machineConfigPath } from "../../bridge/src/config";
20
+ import { installClaudeHook, installCodexHook } from "../../bridge/src/install";
21
+ import { randomId } from "../../../packages/core/crypto";
22
+ import type { Payload } from "../../../packages/protocol/schema";
23
+
24
+ const ASK_TIMEOUT_MS = Number(
25
+ process.env.GRANTTAP_ASK_TIMEOUT_MS ?? process.env.NODVOX_ASK_TIMEOUT_MS ?? 180_000,
26
+ );
27
+
28
+ /** One reconnecting relay client shared by all tool calls. Failed startup is not cached. */
29
+ let client: RelayClient | null = null;
30
+
31
+ async function relay(): Promise<RelayClient | null> {
32
+ try {
33
+ if (!client) {
34
+ const cfg = loadConfig(machineConfigPath());
35
+ client = new RelayClient(cfg, { autoReconnect: true });
36
+ }
37
+ await client.connect();
38
+ return client;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ const NOT_PAIRED =
45
+ "GrantTap is not paired on this machine. Pair the desktop bridge with the GrantTap app first.";
46
+
47
+ async function main(): Promise<void> {
48
+ const server = new McpServer({ name: "granttap", version: "0.1.0" });
49
+
50
+ server.tool(
51
+ "notify",
52
+ "Push a short status/message to the user's phone. Fire-and-forget — use it to keep them informed without blocking.",
53
+ { message: z.string().describe("Text to show on the phone") },
54
+ async ({ message }) => {
55
+ const c = await relay();
56
+ if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
57
+ await c.send(
58
+ { type: "agent.event", text: message, kind: "status", createdAt: Date.now() },
59
+ "phone",
60
+ { ttlMs: 15 * 60_000 },
61
+ );
62
+ return { content: [{ type: "text", text: "sent to phone" }] };
63
+ },
64
+ );
65
+
66
+ server.tool(
67
+ "ask_yes_no",
68
+ "Ask the user a yes/no question on their phone/watch and wait for the tap. Returns 'yes' or 'no'.",
69
+ { question: z.string().describe("A question answerable with yes/no") },
70
+ async ({ question }) => {
71
+ const c = await relay();
72
+ if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
73
+ const requestId = randomId(6);
74
+ await c.send(
75
+ {
76
+ type: "approval.request",
77
+ requestId,
78
+ agent: "granttap",
79
+ kind: "permission",
80
+ tool: "ask",
81
+ title: question,
82
+ risk: "low",
83
+ createdAt: Date.now(),
84
+ },
85
+ "phone",
86
+ { ttlMs: ASK_TIMEOUT_MS },
87
+ );
88
+ const decision = await c
89
+ .waitFor(
90
+ (p: Payload): p is Extract<Payload, { type: "approval.decision" }> =>
91
+ p.type === "approval.decision" && p.requestId === requestId,
92
+ ASK_TIMEOUT_MS,
93
+ )
94
+ .catch(() => null);
95
+ const answer = decision ? (decision.decision === "allow" ? "yes" : "no") : "no-answer (timeout)";
96
+ return { content: [{ type: "text", text: answer }] };
97
+ },
98
+ );
99
+
100
+ server.tool(
101
+ "ask",
102
+ "Ask the user an open question on their phone/watch and wait for their spoken or typed reply. Returns their answer text.",
103
+ { question: z.string().describe("The question to ask") },
104
+ async ({ question }) => {
105
+ const c = await relay();
106
+ if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
107
+ const requestId = randomId(6);
108
+ await c.send(
109
+ { type: "agent.event", text: question, requestId, kind: "question", createdAt: Date.now() },
110
+ "phone",
111
+ { ttlMs: ASK_TIMEOUT_MS },
112
+ );
113
+ const reply = await c
114
+ .waitFor(
115
+ (p: Payload): p is Extract<Payload, { type: "user.message" }> =>
116
+ p.type === "user.message" && p.requestId === requestId,
117
+ ASK_TIMEOUT_MS,
118
+ )
119
+ .catch(() => null);
120
+ return {
121
+ content: [{ type: "text", text: reply ? reply.text : "no-answer (timeout)" }],
122
+ };
123
+ },
124
+ );
125
+
126
+ server.tool(
127
+ "setup",
128
+ "Register the GrantTap approval hooks for Claude Code and Codex on this machine.",
129
+ {},
130
+ async () => {
131
+ const claude = installClaudeHook();
132
+ const codex = installCodexHook();
133
+ return {
134
+ content: [
135
+ { type: "text", text: `Claude: ${claude.status} (${claude.detail})\nCodex: ${codex.status} (${codex.detail})` },
136
+ ],
137
+ };
138
+ },
139
+ );
140
+
141
+ await server.connect(new StdioServerTransport());
142
+ }
143
+
144
+ main().catch((err) => {
145
+ process.stderr.write(`[granttap-mcp] ${(err as Error).message}\n`);
146
+ process.exit(1);
147
+ });
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const [command = "mcp", argument] = process.argv.slice(2);
8
+
9
+ let entry;
10
+ if (command === "mcp") {
11
+ entry = join(root, "apps", "mcp", "src", "server.ts");
12
+ } else if (command === "setup") {
13
+ entry = join(root, "apps", "bridge", "src", "bin", "setup.ts");
14
+ } else if (command === "hook" && (argument === "claude" || argument === "codex")) {
15
+ entry = join(root, "apps", "bridge", "src", "bin", `${argument}-hook.ts`);
16
+ } else {
17
+ process.stderr.write(
18
+ [
19
+ "Usage:",
20
+ " granttap-mcp Start the MCP stdio server",
21
+ " granttap-mcp setup Install Claude Code and Codex approval hooks",
22
+ " granttap-mcp hook <agent> Internal hook entry point",
23
+ "",
24
+ ].join("\n"),
25
+ );
26
+ process.exit(1);
27
+ }
28
+
29
+ const child = spawn(process.execPath, ["--import", "tsx", entry], {
30
+ stdio: "inherit",
31
+ env: process.env,
32
+ });
33
+
34
+ for (const signal of ["SIGINT", "SIGTERM"]) {
35
+ process.on(signal, () => child.kill(signal));
36
+ }
37
+
38
+ child.on("error", (error) => {
39
+ process.stderr.write(`[granttap-mcp] ${error.message}\n`);
40
+ process.exit(1);
41
+ });
42
+
43
+ child.on("exit", (code, signal) => {
44
+ if (signal) process.kill(process.pid, signal);
45
+ else process.exit(code ?? 1);
46
+ });
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "granttap-mcp",
3
+ "version": "0.1.0",
4
+ "description": "E2EE MCP bridge and approval hooks for GrantTap on iPhone and Apple Watch.",
5
+ "type": "module",
6
+ "bin": {
7
+ "granttap-mcp": "./bin/granttap-mcp.mjs"
8
+ },
9
+ "files": [
10
+ "apps",
11
+ "bin",
12
+ "packages",
13
+ "LICENSE",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "start": "node bin/granttap-mcp.mjs",
18
+ "setup": "node bin/granttap-mcp.mjs setup",
19
+ "test": "node --import tsx --test tests/*.test.ts",
20
+ "typecheck": "tsc --noEmit",
21
+ "prepublishOnly": "npm test && npm run typecheck"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "tsx": "^4.19.2",
26
+ "tweetnacl": "^1.0.3",
27
+ "ws": "^8.18.0",
28
+ "zod": "^3.24.1"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.10.2",
32
+ "@types/ws": "^8.5.13",
33
+ "typescript": "^5.7.2"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/sergii-ziborov/granttap-mcp.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/sergii-ziborov/granttap-mcp/issues"
44
+ },
45
+ "homepage": "https://granttap.com",
46
+ "keywords": [
47
+ "mcp",
48
+ "model-context-protocol",
49
+ "claude-code",
50
+ "codex",
51
+ "apple-watch",
52
+ "e2ee",
53
+ "approvals"
54
+ ],
55
+ "license": "MIT",
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "overrides": {
60
+ "@hono/node-server": "2.0.11"
61
+ }
62
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * End-to-end encryption primitives.
3
+ *
4
+ * We use NaCl public-key authenticated encryption (Curve25519 + XSalsa20 +
5
+ * Poly1305) via tweetnacl — the same battle-tested stack the reference
6
+ * open-source client (Happy) uses. Each device holds its own secret key and
7
+ * knows only the peer's PUBLIC key, so the relay (which sees neither secret)
8
+ * can never read message contents.
9
+ *
10
+ * Pairing today = exchange public keys out of band (a generated file; later a
11
+ * QR code scanned by the phone). Nothing here ever leaves the device unsealed.
12
+ */
13
+ import nacl from "tweetnacl";
14
+ import { createHash } from "node:crypto";
15
+
16
+ export type KeyPair = { publicKey: string; secretKey: string }; // base64
17
+
18
+ const b64 = (u: Uint8Array): string => Buffer.from(u).toString("base64");
19
+ const ub64 = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, "base64"));
20
+
21
+ export function generateKeyPair(): KeyPair {
22
+ const kp = nacl.box.keyPair();
23
+ return { publicKey: b64(kp.publicKey), secretKey: b64(kp.secretKey) };
24
+ }
25
+
26
+ /** Seal a JSON-serialisable payload for `theirPublicKey`. */
27
+ export function seal(
28
+ payload: unknown,
29
+ theirPublicKey: string,
30
+ mySecretKey: string,
31
+ ): { nonce: string; box: string } {
32
+ const msg = new TextEncoder().encode(JSON.stringify(payload));
33
+ const nonce = nacl.randomBytes(nacl.box.nonceLength);
34
+ const box = nacl.box(msg, nonce, ub64(theirPublicKey), ub64(mySecretKey));
35
+ return { nonce: b64(nonce), box: b64(box) };
36
+ }
37
+
38
+ /** Open a sealed payload. Returns null on any auth/parse failure. */
39
+ export function open(
40
+ nonce: string,
41
+ box: string,
42
+ theirPublicKey: string,
43
+ mySecretKey: string,
44
+ ): unknown | null {
45
+ let opened: Uint8Array | null = null;
46
+ try {
47
+ opened = nacl.box.open(ub64(box), ub64(nonce), ub64(theirPublicKey), ub64(mySecretKey));
48
+ } catch {
49
+ return null;
50
+ }
51
+ if (!opened) return null;
52
+ try {
53
+ return JSON.parse(new TextDecoder().decode(opened));
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Short random hex id (rooms, request ids, device ids). */
60
+ export function randomId(bytes = 8): string {
61
+ return Buffer.from(nacl.randomBytes(bytes)).toString("hex");
62
+ }
63
+
64
+ // ------------------------------------------------- short pairing code (typed)
65
+ //
66
+ // A QR needs a camera; the simulator (and a tired thumb) needs something you can
67
+ // type. The machine seals the pairing under a key derived from an 8-character
68
+ // code and parks that ciphertext on the relay. The relay still learns nothing —
69
+ // it only ever holds a blob it cannot open — and the code is single-use with a
70
+ // short TTL, which is what keeps 8 characters from being brute-forceable.
71
+
72
+ /** Unambiguous alphabet: no 0/O/1/I/L to avoid transcription mistakes. */
73
+ const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
74
+
75
+ export function generatePairingCode(length = 8): string {
76
+ const bytes = nacl.randomBytes(length);
77
+ let out = "";
78
+ for (let i = 0; i < length; i++) {
79
+ out += CODE_ALPHABET[bytes[i]! % CODE_ALPHABET.length];
80
+ }
81
+ return out;
82
+ }
83
+
84
+ export function normalizeCode(code: string): string {
85
+ return code.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
86
+ }
87
+
88
+ /** Key = SHA-256(normalized code). Mirrored byte-for-byte on the Swift side. */
89
+ function codeKey(code: string): Uint8Array {
90
+ return new Uint8Array(createHash("sha256").update(normalizeCode(code), "utf8").digest());
91
+ }
92
+
93
+ export function sealWithCode(payload: unknown, code: string): { nonce: string; box: string } {
94
+ const msg = new TextEncoder().encode(JSON.stringify(payload));
95
+ const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
96
+ const box = nacl.secretbox(msg, nonce, codeKey(code));
97
+ return { nonce: b64(nonce), box: b64(box) };
98
+ }
99
+
100
+ export function openWithCode(nonce: string, box: string, code: string): unknown | null {
101
+ let opened: Uint8Array | null = null;
102
+ try {
103
+ opened = nacl.secretbox.open(ub64(box), ub64(nonce), codeKey(code));
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (!opened) return null;
108
+ try {
109
+ return JSON.parse(new TextDecoder().decode(opened));
110
+ } catch {
111
+ return null;
112
+ }
113
+ }