granttap-mcp 0.8.79 → 0.8.80
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.
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/core/relay-client.ts +17 -23
- package/packages/core/relay-envelope.ts +8 -1
- package/packages/core/socket-lifecycle/README.md +9 -0
- package/packages/core/socket-lifecycle/index.ts +1 -0
- package/packages/core/socket-lifecycle/reconnect-backoff.ts +29 -0
- package/packages/core/socket-lifecycle/upgrade-headers.ts +1 -2
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.80
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.80\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.80";
|
|
11
11
|
const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
|
|
12
12
|
const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
|
|
13
13
|
? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
|
package/package.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/** Shared authenticated relay transport. See README.md for delivery and encryption boundaries. */
|
|
2
2
|
import WebSocket from "ws";
|
|
3
|
-
import { ManagedRelayLease, RelaySocketHealth, relayUpgradeHeaders } from "./socket-lifecycle";
|
|
3
|
+
import { ManagedRelayLease, RelaySocketHealth, RelayReconnectBackoff, relayUpgradeHeaders } from "./socket-lifecycle";
|
|
4
4
|
import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
|
|
5
5
|
import { openRelayMessage } from "./relay-inbound";
|
|
6
6
|
import { consumeTaskJobOffer, type TaskDispatchListener } from "./task-dispatch";
|
|
7
7
|
import { Payload, type Role } from "../protocol/schema";
|
|
8
|
-
import { peerPublicKeys
|
|
8
|
+
import { peerPublicKeys } from "./crypto";
|
|
9
9
|
import type { PeerConfig, RelayClientOptions, SendOptions } from "./relay-client-types";
|
|
10
10
|
export type { PeerConfig, RelayClientOptions, SendOptions } from "./relay-client-types";
|
|
11
11
|
import { RelayMessageAcceptance } from "./replay-store";
|
|
12
|
-
import { sealEnvelope } from "./relay-envelope";
|
|
12
|
+
import { sealEnvelope, sealSessionPayload } from "./relay-envelope";
|
|
13
13
|
export { sealEnvelope } from "./relay-envelope";
|
|
14
|
-
|
|
15
14
|
/** Return true only after this consumer has durably accepted the payload. */
|
|
16
15
|
type Listener = (p: Payload, peerPublicKey: string) => boolean | void | Promise<boolean | void>;
|
|
17
16
|
|
|
@@ -25,7 +24,7 @@ export class RelayClient {
|
|
|
25
24
|
private intentionalClose = false;
|
|
26
25
|
private connectPromise?: Promise<void>;
|
|
27
26
|
private reconnectTimer?: NodeJS.Timeout;
|
|
28
|
-
private
|
|
27
|
+
private readonly reconnect: RelayReconnectBackoff;
|
|
29
28
|
private readonly socketHealth = new RelaySocketHealth();
|
|
30
29
|
private readonly admissionLease = new ManagedRelayLease();
|
|
31
30
|
private readonly sessionKeys = new Map<string, string>();
|
|
@@ -35,7 +34,7 @@ export class RelayClient {
|
|
|
35
34
|
private readonly cfg: PeerConfig,
|
|
36
35
|
private readonly opts: RelayClientOptions = {},
|
|
37
36
|
) {
|
|
38
|
-
this.
|
|
37
|
+
this.reconnect = new RelayReconnectBackoff(opts.minReconnectMs, opts.maxReconnectMs);
|
|
39
38
|
if (opts.onTaskDispatch) this.taskDispatchListeners.add(opts.onTaskDispatch);
|
|
40
39
|
this.replay = new RelayMessageAcceptance(opts.replayPath);
|
|
41
40
|
}
|
|
@@ -57,6 +56,7 @@ export class RelayClient {
|
|
|
57
56
|
connect(timeoutMs = 10_000): Promise<void> {
|
|
58
57
|
if (this.isConnected) return Promise.resolve();
|
|
59
58
|
if (this.connectPromise) return this.connectPromise;
|
|
59
|
+
if (this.reconnectTimer) return Promise.reject(new Error('Relay reconnect is waiting for its retry window.'));
|
|
60
60
|
|
|
61
61
|
this.intentionalClose = false;
|
|
62
62
|
this.recipientRouting = false;
|
|
@@ -76,7 +76,7 @@ export class RelayClient {
|
|
|
76
76
|
ws.send(frame, error => error ? reject(error) : resolve());
|
|
77
77
|
}));
|
|
78
78
|
this.frames = frames;
|
|
79
|
-
let opened = false;
|
|
79
|
+
let opened = false, openedAt: number | undefined;
|
|
80
80
|
const timer = setTimeout(() => {
|
|
81
81
|
ws.terminate();
|
|
82
82
|
reject(new Error(`relay connect timeout (${this.cfg.relayUrl})`));
|
|
@@ -85,7 +85,7 @@ export class RelayClient {
|
|
|
85
85
|
ws.on("open", () => {
|
|
86
86
|
opened = true;
|
|
87
87
|
clearTimeout(timer);
|
|
88
|
-
|
|
88
|
+
openedAt = Date.now();
|
|
89
89
|
this.socketHealth.start(ws, this.opts.pingIntervalMs ?? 20_000);
|
|
90
90
|
this.admissionLease.start(ws, this.opts);
|
|
91
91
|
// Announce ourselves. The envelope's cleartext (room, from) is what the
|
|
@@ -99,16 +99,21 @@ export class RelayClient {
|
|
|
99
99
|
resolve();
|
|
100
100
|
});
|
|
101
101
|
ws.on("message", (data: WebSocket.RawData) => void this.onRaw(data.toString()));
|
|
102
|
+
ws.on('unexpected-response', (_request, response) => {
|
|
103
|
+
this.reconnect.defer(response.headers['retry-after']);
|
|
104
|
+
response.resume(); ws.terminate();
|
|
105
|
+
clearTimeout(timer); reject(new Error('Relay upgrade was not accepted.'));
|
|
106
|
+
});
|
|
102
107
|
ws.on("error", (err) => {
|
|
103
108
|
clearTimeout(timer);
|
|
104
109
|
if (!opened) reject(err);
|
|
105
110
|
});
|
|
106
|
-
ws.on("close", () => {
|
|
111
|
+
ws.on("close", (code, reason) => {
|
|
107
112
|
frames.cancel();
|
|
108
113
|
clearTimeout(timer);
|
|
109
114
|
this.socketHealth.stop(ws);
|
|
110
115
|
this.admissionLease.stop(ws);
|
|
111
|
-
if (this.ws === ws) this.ws = undefined;
|
|
116
|
+
if (this.ws === ws) { this.reconnect.closed(openedAt, code, reason.toString()); this.ws = undefined; }
|
|
112
117
|
if (!opened) reject(new Error(`relay connection closed (${this.cfg.relayUrl})`));
|
|
113
118
|
this.scheduleReconnect();
|
|
114
119
|
});
|
|
@@ -219,16 +224,7 @@ export class RelayClient {
|
|
|
219
224
|
to: Role | "all" = this.otherRole(),
|
|
220
225
|
options: SendOptions = {},
|
|
221
226
|
): Promise<void> {
|
|
222
|
-
|
|
223
|
-
if (!key) throw new Error(`no encryption key for task ${sessionId}`);
|
|
224
|
-
const sealed = sealWithTransferKey(payload, key);
|
|
225
|
-
await this.send({
|
|
226
|
-
type: "session.sealed",
|
|
227
|
-
sessionId,
|
|
228
|
-
nonce: sealed.nonce,
|
|
229
|
-
box: sealed.box,
|
|
230
|
-
createdAt: Date.now(),
|
|
231
|
-
}, to, options);
|
|
227
|
+
await this.send(sealSessionPayload(payload, sessionId, this.sessionKeys.get(sessionId)), to, options);
|
|
232
228
|
}
|
|
233
229
|
|
|
234
230
|
/** Register before connect so only durable receivers advertise task-job support. */
|
|
@@ -284,9 +280,7 @@ export class RelayClient {
|
|
|
284
280
|
|
|
285
281
|
private scheduleReconnect(): void {
|
|
286
282
|
if (!this.opts.autoReconnect || this.intentionalClose || this.reconnectTimer) return;
|
|
287
|
-
const delay = this.
|
|
288
|
-
const max = this.opts.maxReconnectMs ?? 15_000;
|
|
289
|
-
this.reconnectDelay = Math.min(max, delay * 2);
|
|
283
|
+
const delay = this.reconnect.delay();
|
|
290
284
|
this.reconnectTimer = setTimeout(() => {
|
|
291
285
|
this.reconnectTimer = undefined;
|
|
292
286
|
void this.connect().catch(() => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Envelope, PROTOCOL_VERSION, type Payload, type Role } from "../protocol/schema";
|
|
3
3
|
import { publicKeyIdentifier } from "./ciphertext-recipient";
|
|
4
|
-
import { seal } from "./crypto";
|
|
4
|
+
import { seal, sealWithTransferKey } from "./crypto";
|
|
5
5
|
import type { PeerConfig, SendOptions } from "./relay-client-types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -37,3 +37,10 @@ export function sealEnvelope(
|
|
|
37
37
|
};
|
|
38
38
|
return JSON.stringify(envelope);
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/** Apply the independent task encryption layer before transport encryption. */
|
|
42
|
+
export function sealSessionPayload(payload: Payload, sessionId: string, key: string | undefined): Payload {
|
|
43
|
+
if (!key) throw new Error(`no encryption key for task ${sessionId}`);
|
|
44
|
+
const { nonce, box } = sealWithTransferKey(payload, key);
|
|
45
|
+
return { type: 'session.sealed', sessionId, nonce, box, createdAt: Date.now() };
|
|
46
|
+
}
|
|
@@ -6,6 +6,15 @@ socket and does not rotate task keys or reload transcript state. A failed lookup
|
|
|
6
6
|
cannot extend the server's signed expiry. Closing or replacing a socket cancels
|
|
7
7
|
only its own timers.
|
|
8
8
|
|
|
9
|
+
Reconnect pacing resets only after a non-policy connection lived at least 30
|
|
10
|
+
seconds. Short handshakes keep exponential backoff and jitter. WebSocket
|
|
11
|
+
`retry-after` reasons and HTTP `Retry-After` are lower bounds even when they
|
|
12
|
+
exceed the ordinary retry cap. Foreground polling cannot bypass a scheduled
|
|
13
|
+
retry. None of these waits delay a submission already persisted on the relay.
|
|
14
|
+
Every endpoint advertises its public-key digest before the upgrade, including
|
|
15
|
+
transient machine hooks, so a server can refuse a cooled identity before it
|
|
16
|
+
receives any ciphertext. This digest grants no permissions or decryption rights.
|
|
17
|
+
|
|
9
18
|
The real WebSocket integration is in `tests/core/managed-relay-renewal.test.ts`.
|
|
10
19
|
|
|
11
20
|
Licensed under the GrantTap Commercial Source License in the repository root.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Retry pacing survives refused handshakes and short-lived WebSockets. */
|
|
2
|
+
export class RelayReconnectBackoff {
|
|
3
|
+
private next: number;
|
|
4
|
+
private notBefore = 0;
|
|
5
|
+
|
|
6
|
+
constructor(private readonly minimum = 1_000, private readonly maximum = 15_000,
|
|
7
|
+
private readonly now = Date.now, private readonly random = Math.random) {
|
|
8
|
+
this.next = minimum;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
closed(openedAt: number | undefined, code: number, reason: string): void {
|
|
12
|
+
const policy = code === 1008 || code === 1009 || (code >= 4400 && code <= 4499);
|
|
13
|
+
if (!policy && openedAt !== undefined && this.now() - openedAt >= 30_000) this.next = this.minimum;
|
|
14
|
+
this.defer(/(?:^|;\s*)retry-after=(\d{1,5})(?:;|$)/.exec(reason)?.[1]);
|
|
15
|
+
if (policy) this.notBefore = Math.max(this.notBefore, this.now() + Math.min(60_000, this.maximum));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Server Retry-After is a floor, independent of the ordinary reconnect cap. */
|
|
19
|
+
defer(value: string | string[] | undefined): void {
|
|
20
|
+
if (typeof value !== 'string' || !/^\d{1,5}$/.test(value)) return;
|
|
21
|
+
this.notBefore = Math.max(this.notBefore, this.now() + Number(value) * 1_000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
delay(): number {
|
|
25
|
+
const jittered = Math.round(Math.max(this.minimum, Math.min(this.maximum, this.next * (0.8 + this.random() * 0.4))));
|
|
26
|
+
this.next = Math.min(this.maximum, this.next * 2);
|
|
27
|
+
return Math.max(jittered, this.notBefore - this.now());
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -4,8 +4,7 @@ import { publicKeyIdentifier } from '../ciphertext-recipient';
|
|
|
4
4
|
/** Bind optional mailbox routing to this endpoint's real encryption identity. */
|
|
5
5
|
export function relayUpgradeHeaders(cfg: PeerConfig, admission: Record<string, string>, durableReceiver: boolean) {
|
|
6
6
|
return { ...admission, 'X-GrantTap-Recipient-Routing': '1',
|
|
7
|
-
|
|
8
|
-
? { 'X-GrantTap-Recipient-Key-Id': publicKeyIdentifier(cfg.myPublicKey) } : {}),
|
|
7
|
+
'X-GrantTap-Recipient-Key-Id': publicKeyIdentifier(cfg.myPublicKey),
|
|
9
8
|
...(durableReceiver ? { 'X-GrantTap-Task-Dispatch': '1' } : {}),
|
|
10
9
|
...(cfg.pushAuth ? { Authorization: `Bearer ${cfg.pushAuth}` } : {}) };
|
|
11
10
|
}
|