granttap-mcp 0.8.101 → 0.8.102
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/README.md +7 -0
- package/apps/bridge/src/bin/runtime/monitor.ts +8 -0
- package/apps/bridge/src/monitor/support/runtime.ts +34 -22
- package/apps/bridge/src/runtime-updates/README.md +26 -0
- package/apps/bridge/src/runtime-updates/http-readiness.ts +17 -0
- package/apps/bridge/src/runtime-updates/index.ts +68 -0
- package/apps/bridge/src/runtime-updates/readiness.ts +23 -0
- package/apps/bridge/src/runtime-updates/registry.ts +58 -0
- package/apps/bridge/src/runtime-updates/selection.d.mts +10 -0
- package/apps/bridge/src/runtime-updates/selection.mjs +46 -0
- package/apps/bridge/src/runtime-updates/supervision.mjs +28 -0
- package/apps/mcp/src/http/http-server.ts +6 -0
- package/bin/granttap-mcp.mjs +18 -3
- 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/README.md
CHANGED
|
@@ -855,3 +855,10 @@ See [repository observations](apps/bridge/src/mesh/repository-details/README.md)
|
|
|
855
855
|
The relay can retain recipient-encrypted catalogs, Mesh snapshots, capability usage and recent chat heads for reconnect without the computer. This bounded seven-day read cache is separate from the durable encrypted command queue. See [relay state publication](packages/core/retained-state/README.md). The relay never receives decryption keys or readable chat content.
|
|
856
856
|
|
|
857
857
|
Tools & Skills matches recorded usage to the exact computer, provider and execution. Codex's native history projection supplies actual MCP invocation IDs, results and durations, including calls made through Code Mode. Claude Code uses observed tool-use, result and Skill records, aggregating MCP and skill calls before its recent-detail cache is trimmed. Execution summaries remain available when older call details fall outside the recent-event budget. Counts describe recorded evidence; installation and permission do not prove usage. Missing resource measurements remain unknown. See [native capability history](apps/bridge/src/sessions/native-capability-history/README.md).
|
|
858
|
+
|
|
859
|
+
## Automatic runtime updates
|
|
860
|
+
|
|
861
|
+
Installed GrantTap background services check for stable MCP updates automatically.
|
|
862
|
+
Verified updates are staged separately and applied after active requests and pending
|
|
863
|
+
decisions finish. Startup failures restore the previous version. Pairing and task
|
|
864
|
+
policies are retained; source checkouts and running provider processes stay in place.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { startRuntimeUpdates } from "../../runtime-updates";
|
|
2
|
+
import { runtimeUpdateReadiness } from "../../runtime-updates/readiness";
|
|
1
3
|
/** Persistent, terminal-free task sync for the GrantTap phone app on macOS. */
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { RelayClient } from "../../../../../packages/core/relay-client";
|
|
@@ -25,6 +27,8 @@ let stopEndpointPublisher = () => {};
|
|
|
25
27
|
let stopTaskDispatch = () => {};
|
|
26
28
|
let stopTaskNetwork = () => {};
|
|
27
29
|
let stopEngineRecovery = () => {};
|
|
30
|
+
let stopRuntimeUpdates = () => {};
|
|
31
|
+
let stopUpdateReadiness = () => {};
|
|
28
32
|
|
|
29
33
|
try {
|
|
30
34
|
const cfg = applyNetworkRoute(loadConfig(machineConfigPath()));
|
|
@@ -40,6 +44,8 @@ try {
|
|
|
40
44
|
stopTaskNetwork = startTaskNetworkMonitor({ config: cfg, client,
|
|
41
45
|
isLeader: monitor.isLeader, peerAllowed: controllerPeerGate(cfg), publish: monitor.publish });
|
|
42
46
|
stopEndpointPublisher = startEndpointPublisher(cfg);
|
|
47
|
+
stopRuntimeUpdates = startRuntimeUpdates(monitor.canUpdate);
|
|
48
|
+
stopUpdateReadiness = runtimeUpdateReadiness(monitor.canUpdate);
|
|
43
49
|
} catch (error) {
|
|
44
50
|
process.stderr.write(
|
|
45
51
|
`[granttap-mcp] monitor is not paired: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
@@ -50,6 +56,8 @@ try {
|
|
|
50
56
|
const stop = (): void => {
|
|
51
57
|
if (stopping) return;
|
|
52
58
|
stopping = true;
|
|
59
|
+
stopRuntimeUpdates();
|
|
60
|
+
stopUpdateReadiness();
|
|
53
61
|
stopEngineRecovery();
|
|
54
62
|
stopTaskDispatch();
|
|
55
63
|
stopTaskNetwork();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runtimeWorkTracker } from "../../runtime-updates/readiness";
|
|
1
2
|
import { refreshCodexHooks } from "../../codex-hook-trust";
|
|
2
3
|
import { computerDisplayName } from "../../../../../packages/core/computer-name";
|
|
3
4
|
import { RelayClient } from "../../../../../packages/core/relay-client";
|
|
@@ -38,6 +39,7 @@ import { publishMonitorCapabilities, stopMonitorCapabilities } from "../capabili
|
|
|
38
39
|
export type SessionMonitor = {
|
|
39
40
|
publish: () => Promise<void>;
|
|
40
41
|
isLeader: () => boolean;
|
|
42
|
+
canUpdate: () => boolean;
|
|
41
43
|
close: () => void;
|
|
42
44
|
};
|
|
43
45
|
|
|
@@ -163,29 +165,13 @@ async function publishMonitorTick(input: {
|
|
|
163
165
|
export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
164
166
|
const subscriptions = new Set<string>();
|
|
165
167
|
const leadership = monitorLeadership();
|
|
166
|
-
|
|
167
|
-
publishedCatalog(client);
|
|
168
|
+
restoreMonitorState(client);
|
|
168
169
|
let historyCache: { generatedAt: number; sessions: SessionsStatus["sessions"] } | undefined;
|
|
169
170
|
let historyRead: Promise<SessionsStatus["sessions"]> | undefined;
|
|
170
171
|
let lastHistoryPublishedAt = 0;
|
|
171
172
|
let closed = false;
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
});
|
|
175
|
-
const loadLoop = startMachineLoadLoop({
|
|
176
|
-
connected: () => client.isConnected,
|
|
177
|
-
// Only the leader reports load. Every MCP server used to run this loop
|
|
178
|
-
// too, each with the code of the day it started, and the phone flickered
|
|
179
|
-
// between their answers.
|
|
180
|
-
publish: (status, intervalMs) => (leadership.acquire()
|
|
181
|
-
? publishMachineLoad(client, status, intervalMs)
|
|
182
|
-
: Promise.resolve(undefined)),
|
|
183
|
-
onError: (error) => {
|
|
184
|
-
process.stderr.write(
|
|
185
|
-
`[monitor] load report failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
186
|
-
);
|
|
187
|
-
},
|
|
188
|
-
});
|
|
173
|
+
const work = runtimeWorkTracker(() => approvalsStatus().pending.length > 0);
|
|
174
|
+
const loadLoop = monitorLoadLoop(client, leadership);
|
|
189
175
|
|
|
190
176
|
const history = (force = false): Promise<SessionsStatus["sessions"]> => {
|
|
191
177
|
if (historyRead) return historyRead;
|
|
@@ -209,7 +195,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
209
195
|
lastHistoryPublishedAt = next.lastHistoryPublishedAt;
|
|
210
196
|
});
|
|
211
197
|
|
|
212
|
-
const off = client.onMessage(
|
|
198
|
+
const off = client.onMessage((payload: Payload, peerPublicKey) => work.run(async () => {
|
|
213
199
|
if (payload.type === "hello" && payload.role === "phone") {
|
|
214
200
|
confirmPendingController(client.room, peerPublicKey);
|
|
215
201
|
recordControllerName(peerPublicKey, payload.deviceName);
|
|
@@ -219,8 +205,8 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
219
205
|
await replayNativeQuestions(client, peerPublicKey);
|
|
220
206
|
}
|
|
221
207
|
}
|
|
222
|
-
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
|
|
223
|
-
});
|
|
208
|
+
return await handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
|
|
209
|
+
}));
|
|
224
210
|
|
|
225
211
|
const offConnected = client.onConnected?.(() => publish().catch(() => {}));
|
|
226
212
|
|
|
@@ -244,6 +230,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
244
230
|
return {
|
|
245
231
|
publish: () => publish(false),
|
|
246
232
|
isLeader: () => leadership.acquire(),
|
|
233
|
+
canUpdate: work.idle,
|
|
247
234
|
close: () => {
|
|
248
235
|
closed = true;
|
|
249
236
|
offConnected?.();
|
|
@@ -279,3 +266,28 @@ async function replayNativeQuestions(client: RelayClient, peerPublicKey: string)
|
|
|
279
266
|
const known = publishedCatalog(client);
|
|
280
267
|
if (known) await publishCodexQuestions(client, known.sessions, { replay: true, peerPublicKey }).catch(() => {});
|
|
281
268
|
}
|
|
269
|
+
|
|
270
|
+
function restoreMonitorState(client: RelayClient): void {
|
|
271
|
+
primeSessionKeys(client);
|
|
272
|
+
publishedCatalog(client);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function monitorLoadLoop(client: RelayClient, leadership: ReturnType<typeof monitorLeadership>) {
|
|
276
|
+
const publishMachineLoad = createMachineLoadPublisher({
|
|
277
|
+
log: (line) => process.stderr.write(`[monitor] ${line}\n`),
|
|
278
|
+
});
|
|
279
|
+
return startMachineLoadLoop({
|
|
280
|
+
connected: () => client.isConnected,
|
|
281
|
+
// Only the leader reports load. Every MCP server used to run this loop
|
|
282
|
+
// too, each with the code of the day it started, and the phone flickered
|
|
283
|
+
// between their answers.
|
|
284
|
+
publish: (status, intervalMs) => (leadership.acquire()
|
|
285
|
+
? publishMachineLoad(client, status, intervalMs)
|
|
286
|
+
: Promise.resolve(undefined)),
|
|
287
|
+
onError: (error) => {
|
|
288
|
+
process.stderr.write(
|
|
289
|
+
`[monitor] load report failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
290
|
+
);
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Automatic GrantTap runtime updates
|
|
2
|
+
|
|
3
|
+
The installed background monitor checks the official `granttap-mcp` npm stable
|
|
4
|
+
release every four hours. New versions are installed in a separate private
|
|
5
|
+
version directory under `~/.granttap/runtime-updates`, with lifecycle scripts
|
|
6
|
+
disabled, Node engine checks enabled, and registry integrity verified. A failed
|
|
7
|
+
installation leaves the running version in place.
|
|
8
|
+
|
|
9
|
+
The stable launcher selects the verified version for future invocations. Owned
|
|
10
|
+
background services switch only after their current requests finish and no
|
|
11
|
+
approval is pending. Existing provider processes and active stdio MCP calls keep
|
|
12
|
+
running. A new service that cannot start restores the previous version; that
|
|
13
|
+
failed version is quarantined until a newer release appears.
|
|
14
|
+
|
|
15
|
+
Pairing, account credentials, task history, capability policy, and provider
|
|
16
|
+
installations are not modified by runtime updates. Offline checks retry later.
|
|
17
|
+
Source checkouts and test processes do not update themselves. Administrators may
|
|
18
|
+
set `GRANTTAP_DISABLE_AUTO_UPDATE=1` on their service to retain their installed
|
|
19
|
+
version. Private staging consumes space; old versions are retained for rollback.
|
|
20
|
+
|
|
21
|
+
Public entry: `startRuntimeUpdates`, `runtimeUpdateReadiness`, and the launcher's
|
|
22
|
+
`selection.mjs` / `supervision.mjs` boundary. Tests exercise failed downloads and
|
|
23
|
+
verification, deferred activation, concurrent installation, selection, and startup
|
|
24
|
+
rollback using isolated directories and owned child processes.
|
|
25
|
+
|
|
26
|
+
License: [GrantTap Commercial Source License](../../../../LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
|
|
3
|
+
/** Long-lived HTTP/SSE calls defer replacement until their normal completion. */
|
|
4
|
+
export function trackHttpRuntimeWork(app:Express):()=>boolean {
|
|
5
|
+
let activeRequests=0;
|
|
6
|
+
app.use((_req,res,next)=>{
|
|
7
|
+
activeRequests++;
|
|
8
|
+
let finished=false;
|
|
9
|
+
const complete=()=>{
|
|
10
|
+
if(!finished) {finished=true;activeRequests--;}
|
|
11
|
+
};
|
|
12
|
+
res.once('finish',complete);
|
|
13
|
+
res.once('close',complete);
|
|
14
|
+
next();
|
|
15
|
+
});
|
|
16
|
+
return ()=>activeRequests===0;
|
|
17
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { configDir } from '../config/runtime/paths';
|
|
5
|
+
import { latestRelease, trustedRelease, installRelease, verifyRelease, type Release } from './registry';
|
|
6
|
+
import { newerVersion, readState, runtimeVersion, saveState, validRuntime, versionRoot } from './selection.mjs';
|
|
7
|
+
|
|
8
|
+
type Options={root:string;stateDirectory:string;now?:()=>number;latest?:()=>Promise<unknown>;
|
|
9
|
+
idle:()=>boolean;install?:(release:Release,directory:string)=>Promise<void>;
|
|
10
|
+
verify?:(root:string)=>Promise<void>;activate?:(root:string)=>void};
|
|
11
|
+
|
|
12
|
+
/** Stage verified releases independently; activation waits until no request or decision is pending. */
|
|
13
|
+
export async function checkForRuntimeUpdate(options:Options):Promise<string|undefined> {
|
|
14
|
+
mkdirSync(options.stateDirectory,{recursive:true,mode:0o700});
|
|
15
|
+
const lock=join(options.stateDirectory,'install.lock');let fd:number;
|
|
16
|
+
try {fd=openSync(lock,'wx',0o600);} catch {return undefined;}
|
|
17
|
+
try {
|
|
18
|
+
const release=await (options.latest??latestRelease)();
|
|
19
|
+
const state=readState(options.stateDirectory);
|
|
20
|
+
if(!trustedRelease(release) || !newerVersion(release.version,runtimeVersion(options.root))
|
|
21
|
+
|| state.rejectedVersion===release.version || (state.version && !newerVersion(release.version,state.version)))return;
|
|
22
|
+
const candidate=versionRoot(options.stateDirectory,release.version);
|
|
23
|
+
if(!validRuntime(candidate,options.stateDirectory,release.version)) {
|
|
24
|
+
await (options.install??installRelease)(release,options.stateDirectory);
|
|
25
|
+
}
|
|
26
|
+
await (options.verify??verifyRelease)(candidate);
|
|
27
|
+
if(!validRuntime(candidate,options.stateDirectory,release.version))throw Error('Runtime verification failed');
|
|
28
|
+
if(!options.idle())return;
|
|
29
|
+
saveState(options.stateDirectory,{root:candidate,version:release.version,
|
|
30
|
+
previous:state.root?{root:state.root,version:state.version}:undefined});
|
|
31
|
+
options.activate?.(candidate);return candidate;
|
|
32
|
+
} finally {closeSync(fd);unlinkSync(lock);}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type LoopOptions={root:string;directory:string;idle:()=>boolean;check?:typeof checkForRuntimeUpdate;
|
|
36
|
+
now?:()=>number;schedule?:typeof setTimeout;report?:()=>void;activate?:()=>void};
|
|
37
|
+
|
|
38
|
+
/** Check failure never stops sync; timers are owned and cancellation prevents another check. */
|
|
39
|
+
export function runtimeUpdateLoop(options:LoopOptions):()=>void {
|
|
40
|
+
let stopped=false,timer:NodeJS.Timeout|undefined,lastCheck=0;
|
|
41
|
+
const now=options.now??Date.now,schedule=options.schedule??setTimeout;
|
|
42
|
+
const tick=async()=>{
|
|
43
|
+
if(stopped)return;
|
|
44
|
+
try {
|
|
45
|
+
const lock=join(options.directory,'install.lock');
|
|
46
|
+
try {if(now()-statSync(lock).mtimeMs>10*60_000)unlinkSync(lock);} catch { /* no stale lock */ }
|
|
47
|
+
if(now()-lastCheck>=4*60*60_000) {
|
|
48
|
+
await (options.check??checkForRuntimeUpdate)({root:options.root,stateDirectory:options.directory,idle:options.idle,
|
|
49
|
+
activate:()=>options.activate?.()});
|
|
50
|
+
lastCheck=options.idle()?now():0;
|
|
51
|
+
}
|
|
52
|
+
} catch {options.report?.();}
|
|
53
|
+
finally {if(!stopped){timer=schedule(()=>void tick(),60_000);timer.unref();}}
|
|
54
|
+
};
|
|
55
|
+
timer=schedule(()=>void tick(),60_000);timer.unref();
|
|
56
|
+
return ()=>{stopped=true;if(timer)clearTimeout(timer);};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Only the owned installed background monitor downloads updates; source checkouts stay fixed. */
|
|
60
|
+
export function startRuntimeUpdates(idle:()=>boolean):()=>void {
|
|
61
|
+
if(process.env.GRANTTAP_DISABLE_AUTO_UPDATE==='1' || process.env.GRANTTAP_LOCAL==='1'
|
|
62
|
+
|| process.env.NODE_TEST_CONTEXT || !process.send)return ()=>{};
|
|
63
|
+
const root=fileURLToPath(new URL('../../../../',import.meta.url));
|
|
64
|
+
if(!root.includes('node_modules'))return ()=>{};
|
|
65
|
+
return runtimeUpdateLoop({root,directory:join(configDir(),'runtime-updates'),idle,
|
|
66
|
+
activate:()=>process.send?.({type:'granttap.runtime-update-ready'},()=>{}),
|
|
67
|
+
report:()=>process.stderr.write('[granttap-mcp] Automatic runtime update deferred; current version retained.\n')});
|
|
68
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Only the launcher's private IPC channel can ask the owned service to drain for an update. */
|
|
2
|
+
export function runtimeUpdateReadiness(idle:()=>boolean):()=>void {
|
|
3
|
+
const listener=(message:unknown)=>{
|
|
4
|
+
if((message as {type?:string})?.type==='granttap.runtime-update-check' && idle()) {
|
|
5
|
+
process.send?.({type:'granttap.runtime-update-ready'},()=>{});
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
process.on('message',listener);
|
|
9
|
+
process.send?.({type:'granttap.runtime-healthy'},()=>{});
|
|
10
|
+
return ()=>{process.off('message',listener);};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Count async work until completion; an update cannot interrupt a partially delivered command. */
|
|
14
|
+
export function runtimeWorkTracker(blocked:()=>boolean) {
|
|
15
|
+
let count=0;
|
|
16
|
+
return {
|
|
17
|
+
idle:()=>count===0 && !blocked(),
|
|
18
|
+
run:async<T>(work:()=>Promise<T>):Promise<T>=>{
|
|
19
|
+
count++;
|
|
20
|
+
try {return await work();} finally {count--;}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { stableVersion, validRuntime, versionRoot } from './selection.mjs';
|
|
5
|
+
|
|
6
|
+
export type Release = {name:string;version:string;dist:{integrity:string;tarball:string}};
|
|
7
|
+
const registry='https://registry.npmjs.org';
|
|
8
|
+
export function trustedRelease(value:unknown):value is Release {
|
|
9
|
+
const r=value as Release|undefined;
|
|
10
|
+
return r?.name==='granttap-mcp' && stableVersion(r.version)
|
|
11
|
+
&& typeof r.dist?.integrity==='string' && /^sha512-[A-Za-z0-9+/]{86}==$/.test(r.dist.integrity)
|
|
12
|
+
&& r.dist.tarball===`${registry}/granttap-mcp/-/granttap-mcp-${r.version}.tgz`;
|
|
13
|
+
}
|
|
14
|
+
/** Registry metadata is bounded, timed, and never changes the installed runtime by itself. */
|
|
15
|
+
export async function latestRelease():Promise<unknown> {
|
|
16
|
+
const response=await fetch(registry+'/granttap-mcp/latest',{signal:AbortSignal.timeout(10_000),redirect:'error'});
|
|
17
|
+
if(!response.ok || !response.body) throw Error('Runtime registry unavailable');
|
|
18
|
+
const reader=response.body.getReader();let size=0;const chunks:Uint8Array[]=[];
|
|
19
|
+
try { for(;;) {const {done,value}=await reader.read();if(done)break;
|
|
20
|
+
size+=value.byteLength;if(size>262_144)throw Error('Runtime metadata exceeds limit');chunks.push(value);
|
|
21
|
+
} } finally {await reader.cancel();}
|
|
22
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
23
|
+
}
|
|
24
|
+
export function run(command:string,args:string[],timeoutMs=180_000,env:NodeJS.ProcessEnv=process.env):Promise<void> {
|
|
25
|
+
return new Promise((resolve,reject)=>{
|
|
26
|
+
const child=spawn(command,args,{env,stdio:'ignore',windowsHide:true});let done=false;
|
|
27
|
+
const finish=(error?:Error)=>{if(done)return;done=true;clearTimeout(timer);error?reject(error):resolve();};
|
|
28
|
+
const timer=setTimeout(()=>{child.kill();finish(Error('Runtime update command timed out'));},timeoutMs);
|
|
29
|
+
child.once('error',()=>finish(Error('Runtime update command could not start')));
|
|
30
|
+
child.once('exit',code=>finish(code===0?undefined:Error('Runtime update command failed')));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function npmCli():string {
|
|
34
|
+
const bin=dirname(process.execPath);
|
|
35
|
+
for(const path of [join(bin,'npm'),join(bin,'node_modules','npm','bin','npm-cli.js'),
|
|
36
|
+
join(bin,'..','lib','node_modules','npm','bin','npm-cli.js')]) {
|
|
37
|
+
if(existsSync(path) && !path.endsWith('.cmd'))return realpathSync(path);
|
|
38
|
+
}
|
|
39
|
+
throw Error('The installed Node runtime has no npm updater');
|
|
40
|
+
}
|
|
41
|
+
/** npm verifies registry integrity and Node compatibility in a version-specific, isolated prefix. */
|
|
42
|
+
export async function installRelease(release:Release,directory:string,runner=run):Promise<void> {
|
|
43
|
+
await runner(process.execPath,[npmCli(),'install','--prefix',join(directory,'versions',release.version),
|
|
44
|
+
'--registry',registry,'--ignore-scripts','--engine-strict','--omit=dev','--no-audit','--no-fund',
|
|
45
|
+
'--save-exact',`granttap-mcp@${release.version}`]);
|
|
46
|
+
const lock=JSON.parse(readFileSync(join(directory,'versions',release.version,'package-lock.json'),'utf8'));
|
|
47
|
+
const installed=lock.packages?.['node_modules/granttap-mcp'];
|
|
48
|
+
if(installed?.version!==release.version || installed?.integrity!==release.dist.integrity
|
|
49
|
+
|| installed?.resolved!==release.dist.tarball || !validRuntime(versionRoot(directory,release.version),directory,release.version)) {
|
|
50
|
+
throw Error('Installed runtime identity or registry integrity did not match');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function verifyRelease(root:string):Promise<void> {
|
|
54
|
+
await run(process.execPath,[join(root,'bin','granttap-mcp.mjs'),'--help'],15_000,
|
|
55
|
+
{...process.env,GRANTTAP_DISABLE_AUTO_UPDATE:'1'});
|
|
56
|
+
// Validate the launcher without starting services or reading the live pairing.
|
|
57
|
+
await run(process.execPath,['--check',join(root,'bin','granttap-mcp.mjs')],15_000);
|
|
58
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type RuntimeState = {root?:string;version?:string;rejectedVersion?:string;previous?:RuntimeState};
|
|
2
|
+
export function stableVersion(value:unknown):value is string;
|
|
3
|
+
export function newerVersion(candidate:string,current:string):boolean;
|
|
4
|
+
export function readState(directory:string):RuntimeState;
|
|
5
|
+
export function saveState(directory:string,state:RuntimeState):void;
|
|
6
|
+
export function runtimeVersion(root:string):string;
|
|
7
|
+
export function versionRoot(directory:string,version:string):string;
|
|
8
|
+
export function validRuntime(root:string|undefined,directory:string,version:string|undefined):boolean;
|
|
9
|
+
export function selectedRuntime(bundled:string,directory:string):string;
|
|
10
|
+
export function rollbackRuntime(bundled:string,directory:string,failedRoot?:string):boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const stableVersion = value => typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
|
|
5
|
+
export function newerVersion(candidate, current) {
|
|
6
|
+
if (!stableVersion(candidate) || !stableVersion(current)) return false;
|
|
7
|
+
const a=candidate.split('.').map(Number), b=current.split('.').map(Number);
|
|
8
|
+
for(let i=0;i<3;i++) { if(a[i]!==b[i]) return a[i]>b[i]; }
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
export function readState(directory) {
|
|
12
|
+
try { return JSON.parse(readFileSync(join(directory,'active.json'),'utf8')); } catch { return {}; }
|
|
13
|
+
}
|
|
14
|
+
export function saveState(directory, state) {
|
|
15
|
+
mkdirSync(directory,{recursive:true,mode:0o700});
|
|
16
|
+
const path=join(directory,'active.json'), temporary=path+'.'+process.pid+'.tmp';
|
|
17
|
+
writeFileSync(temporary,JSON.stringify(state),{mode:0o600});renameSync(temporary,path);
|
|
18
|
+
}
|
|
19
|
+
export function runtimeVersion(root) {
|
|
20
|
+
const p=JSON.parse(readFileSync(join(root,'package.json'),'utf8'));
|
|
21
|
+
if(p.name!=='granttap-mcp' || !stableVersion(p.version)) throw Error('Invalid GrantTap runtime package');
|
|
22
|
+
return p.version;
|
|
23
|
+
}
|
|
24
|
+
export function versionRoot(directory, version) {
|
|
25
|
+
return join(directory,'versions',version,'node_modules','granttap-mcp');
|
|
26
|
+
}
|
|
27
|
+
export function validRuntime(root, directory, version) {
|
|
28
|
+
try { return stableVersion(version) && resolve(root)===resolve(versionRoot(directory,version))
|
|
29
|
+
&& runtimeVersion(root)===version && existsSync(join(root,'bin','granttap-mcp.mjs')); } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
/** The stable launcher accepts only a verified, newer package inside its private version cache. */
|
|
32
|
+
export function selectedRuntime(bundled, directory) {
|
|
33
|
+
const state=readState(directory);
|
|
34
|
+
return validRuntime(state.root,directory,state.version) && newerVersion(state.version,runtimeVersion(bundled))
|
|
35
|
+
? state.root : bundled;
|
|
36
|
+
}
|
|
37
|
+
/** A new background service that exits during startup restores the last working selection. */
|
|
38
|
+
export function rollbackRuntime(bundled, directory, failedRoot) {
|
|
39
|
+
const state=readState(directory);
|
|
40
|
+
if(!state.version || (failedRoot && state.root!==failedRoot)) return false;
|
|
41
|
+
const rejectedVersion=state.version, previous=state.previous;
|
|
42
|
+
if(previous && validRuntime(previous.root,directory,previous.version)) {
|
|
43
|
+
saveState(directory,{...previous,rejectedVersion});
|
|
44
|
+
} else saveState(directory,{rejectedVersion});
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { selectedRuntime, rollbackRuntime } from './selection.mjs';
|
|
2
|
+
|
|
3
|
+
/** Monitor only the child started by this launcher; external providers are never stopped. */
|
|
4
|
+
export function superviseRuntime(child, options) {
|
|
5
|
+
const {bundled,root,directory}=options;
|
|
6
|
+
const started=(options.now??Date.now)();let healthy=root===bundled,stopped=false;
|
|
7
|
+
const read=()=>selectedRuntime(bundled,directory);
|
|
8
|
+
const changed=()=>read()!==root;
|
|
9
|
+
const stop=()=>{if(stopped)return;stopped=true;clearInterval(timer);child.off('message',onMessage);};
|
|
10
|
+
const onMessage=message=>{
|
|
11
|
+
if(message?.type==='granttap.runtime-healthy')healthy=true;
|
|
12
|
+
if(message?.type==='granttap.runtime-update-ready' && changed()) {stop();child.kill('SIGTERM');}
|
|
13
|
+
};
|
|
14
|
+
const tick=()=>{
|
|
15
|
+
if(stopped)return;
|
|
16
|
+
if(!healthy && (options.now??Date.now)()-started>120_000) {
|
|
17
|
+
rollbackRuntime(bundled,directory,root);stop();child.kill('SIGTERM');return;
|
|
18
|
+
}
|
|
19
|
+
if(changed() && child.connected)child.send({type:'granttap.runtime-update-check'},()=>{});
|
|
20
|
+
};
|
|
21
|
+
const timer=(options.schedule??setInterval)(tick,10_000);timer.unref?.();child.on('message',onMessage);
|
|
22
|
+
return {stop,onExit:(code,signal)=>{
|
|
23
|
+
if(!stopped && !healthy && code!==0 && !['SIGTERM','SIGINT'].includes(signal)) {
|
|
24
|
+
rollbackRuntime(bundled,directory,root);
|
|
25
|
+
}
|
|
26
|
+
stop();
|
|
27
|
+
}};
|
|
28
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { trackHttpRuntimeWork } from "../../../bridge/src/runtime-updates/http-readiness";
|
|
2
|
+
import { runtimeUpdateReadiness } from "../../../bridge/src/runtime-updates/readiness";
|
|
3
|
+
import { approvalsStatus } from "../../../bridge/src/approvals/state";
|
|
1
4
|
/**
|
|
2
5
|
* GrantTap MCP over Streamable HTTP + loopback OAuth with website consent.
|
|
3
6
|
*
|
|
@@ -63,6 +66,7 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
|
|
|
63
66
|
const issuerUrl = new URL(`http://${host === "::1" ? "[::1]" : host}:${port}`);
|
|
64
67
|
const provider = new GrantTapOAuthProvider(mcpUrl.href);
|
|
65
68
|
const app = createMcpExpressApp({ host });
|
|
69
|
+
const httpIdle = trackHttpRuntimeWork(app);
|
|
66
70
|
app.use(express.urlencoded({ extended: false }));
|
|
67
71
|
app.use(mcpAuthRouter({
|
|
68
72
|
provider,
|
|
@@ -120,11 +124,13 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
|
|
|
120
124
|
const stopAccountRecovery = accountOrigin
|
|
121
125
|
? startAccountRecoveryPoller(accountOrigin, refreshRecoveredPairing) : () => {};
|
|
122
126
|
|
|
127
|
+
const stopUpdateReadiness=runtimeUpdateReadiness(()=>httpIdle() && approvalsStatus().pending.length===0);
|
|
123
128
|
return {
|
|
124
129
|
host,
|
|
125
130
|
port,
|
|
126
131
|
mcpUrl: mcpUrl.href,
|
|
127
132
|
close: async () => {
|
|
133
|
+
stopUpdateReadiness();
|
|
128
134
|
stopAccountRecovery();
|
|
129
135
|
await sessions.close();
|
|
130
136
|
resetConnectWatchers();
|
package/bin/granttap-mcp.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { selectedRuntime, runtimeVersion, rollbackRuntime } from "../apps/bridge/src/runtime-updates/selection.mjs";
|
|
6
|
+
import { superviseRuntime } from "../apps/bridge/src/runtime-updates/supervision.mjs";
|
|
4
7
|
import { createRequire } from "node:module";
|
|
5
8
|
import { delimiter, dirname, join } from "node:path";
|
|
6
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -12,7 +15,12 @@ if (!isSupportedNode(process.versions.node)) {
|
|
|
12
15
|
process.exit(1);
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
const
|
|
18
|
+
const bundledRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
const updateDirectory = join(process.env.GRANTTAP_CONFIG_DIR ?? process.env.NODVOX_CONFIG_DIR
|
|
20
|
+
?? join(homedir(), ".granttap"), "runtime-updates");
|
|
21
|
+
const managed = bundledRoot.includes("node_modules") && process.env.GRANTTAP_LOCAL !== "1"
|
|
22
|
+
&& process.env.GRANTTAP_DISABLE_AUTO_UPDATE !== "1" && !process.env.NODE_TEST_CONTEXT;
|
|
23
|
+
const root = managed ? selectedRuntime(bundledRoot, updateDirectory) : bundledRoot;
|
|
16
24
|
const [command = "mcp", ...commandArgs] = process.argv.slice(2);
|
|
17
25
|
const argument = commandArgs[0];
|
|
18
26
|
|
|
@@ -85,7 +93,7 @@ if (command === "mcp") {
|
|
|
85
93
|
|
|
86
94
|
// Cursor (and LaunchAgents) often spawn with cwd=$HOME. Bare `--import tsx` then
|
|
87
95
|
// resolves from home and dies with ERR_MODULE_NOT_FOUND. Pin absolute loaders + cwd.
|
|
88
|
-
const requireFromBin = createRequire(
|
|
96
|
+
const requireFromBin = createRequire(join(root,"package.json"));
|
|
89
97
|
let preflight;
|
|
90
98
|
let loader;
|
|
91
99
|
try {
|
|
@@ -105,6 +113,7 @@ try {
|
|
|
105
113
|
}
|
|
106
114
|
}
|
|
107
115
|
if (!preflight || !loader) {
|
|
116
|
+
if (root !== bundledRoot) rollbackRuntime(bundledRoot, updateDirectory, root);
|
|
108
117
|
process.stderr.write(
|
|
109
118
|
"[granttap-mcp] unable to resolve the installed tsx runtime — reinstall granttap-mcp\n",
|
|
110
119
|
);
|
|
@@ -114,22 +123,27 @@ if (!preflight || !loader) {
|
|
|
114
123
|
const forwardedArgs = ["connect", "status", "reset", "mesh", "setup", "restrictions", "phone"].includes(command)
|
|
115
124
|
? (command === "mesh" || command === "restrictions" ? commandArgs.slice(1) : commandArgs)
|
|
116
125
|
: [];
|
|
126
|
+
const background = command === "internal" && ["monitor", "serve"].includes(argument);
|
|
117
127
|
const child = spawn(
|
|
118
128
|
process.execPath,
|
|
119
129
|
["--require", preflight, "--import", pathToFileURL(loader).href,
|
|
120
130
|
"--import", pathToFileURL(join(root, "apps", "bridge", "src", "engine", "startup", "activation-entry.ts")).href,
|
|
121
131
|
entry, ...forwardedArgs],
|
|
122
132
|
{
|
|
123
|
-
stdio: "inherit",
|
|
133
|
+
stdio: background ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
|
|
124
134
|
cwd: root,
|
|
125
135
|
env: {
|
|
126
136
|
...process.env,
|
|
137
|
+
GRANTTAP_PACKAGE_VERSION: runtimeVersion(root),
|
|
127
138
|
NODE_PATH: [join(root, "node_modules"), process.env.NODE_PATH].filter(Boolean).join(delimiter),
|
|
128
139
|
},
|
|
129
140
|
},
|
|
130
141
|
);
|
|
131
142
|
|
|
143
|
+
const supervision = background && managed ? superviseRuntime(child, {bundled:bundledRoot, root, directory:updateDirectory}) : undefined;
|
|
144
|
+
|
|
132
145
|
function stopChild() {
|
|
146
|
+
supervision?.stop();
|
|
133
147
|
try {
|
|
134
148
|
if (process.platform === "win32") child.kill();
|
|
135
149
|
else child.kill("SIGTERM");
|
|
@@ -148,6 +162,7 @@ child.on("error", (error) => {
|
|
|
148
162
|
});
|
|
149
163
|
|
|
150
164
|
child.on("exit", (code, signal) => {
|
|
165
|
+
supervision?.onExit(code, signal);
|
|
151
166
|
if (signal && process.platform !== "win32") {
|
|
152
167
|
try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }
|
|
153
168
|
} else process.exit(code ?? 1);
|
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.102
|
|
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.102\";\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 22.13.0+ 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.102";
|
|
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;
|