@bridge_gpt/mcp-server 0.2.68 → 0.2.69
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 +99 -41
- package/build/bridge-permissions.js +627 -94
- package/build/check-update-cli.js +74 -0
- package/build/claude-settings.js +84 -2
- package/build/cli-help.js +7 -1
- package/build/codex-home.js +9 -1
- package/build/codex-launcher-pin.js +598 -0
- package/build/commands.generated.js +5 -5
- package/build/connect-github-api.js +33 -14
- package/build/docs.generated.js +2 -2
- package/build/doctor.js +109 -6
- package/build/epic-implementer-bin.js +80 -85
- package/build/executor/completion-contract.js +2 -0
- package/build/executor/http-client.js +43 -4
- package/build/index.js +464 -246
- package/build/install-bridge.js +260 -297
- package/build/launcher-config-inspection.js +184 -19
- package/build/mcp-host-targets.js +4 -0
- package/build/mcp-identity.js +7 -0
- package/build/pipelines.generated.js +4 -4
- package/build/readme.generated.js +1 -1
- package/build/session-upgrade-notice.js +86 -0
- package/build/start-tickets.js +15 -0
- package/build/tool-call-advisory.js +162 -0
- package/build/tool-surface-gating.js +28 -89
- package/build/update-check.js +97 -16
- package/build/update-status.js +61 -71
- package/build/upgrade-advice.js +15 -11
- package/build/upgrade-cli.js +347 -20
- package/build/version.generated.js +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single "should this session be told about an upgrade?" decision (BAPI-1446).
|
|
3
|
+
*
|
|
4
|
+
* Every notice surface asks here: the startup instructions, the first completed
|
|
5
|
+
* tool result, the Claude SessionStart hook, the `ping` advice, and the
|
|
6
|
+
* `start-tickets` reminder. One synchronous, non-throwing function holds every
|
|
7
|
+
* eligibility and silence rule, so the surfaces cannot drift apart.
|
|
8
|
+
*
|
|
9
|
+
* Nothing in this module touches the network or starts asynchronous work. The
|
|
10
|
+
* only I/O is the synchronous read of the local update cache.
|
|
11
|
+
*/
|
|
12
|
+
import { parseDefaultOnEnvFlag } from "./env-flags.js";
|
|
13
|
+
import { isNewerVersion, isPlainReleaseVersion, readCachedLatestVersion, readFakeLatestOverride, } from "./update-check.js";
|
|
14
|
+
import { formatSessionUpgradeNotice } from "./upgrade-advice.js";
|
|
15
|
+
/** The kill switch for every update check and notice. Default on. */
|
|
16
|
+
export const UPDATE_CHECK_ENABLED_ENV = "BAPI_MCP_UPDATE_CHECK_ENABLED";
|
|
17
|
+
/** Worker markers: either one means no person is watching the session. */
|
|
18
|
+
export const UNATTENDED_MARKER_ENVS = [
|
|
19
|
+
"BAPI_WORKER_BRANCH",
|
|
20
|
+
"BAPI_EPIC_IMPLEMENTER_WORKER_ID",
|
|
21
|
+
];
|
|
22
|
+
const SILENT = { show: false };
|
|
23
|
+
/** True when either worker marker holds a non-empty string. */
|
|
24
|
+
export function isUnattendedSession(env) {
|
|
25
|
+
return UNATTENDED_MARKER_ENVS.some((name) => {
|
|
26
|
+
const value = env[name];
|
|
27
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Decide whether to show the upgrade notice. Never throws and never starts
|
|
32
|
+
* asynchronous work.
|
|
33
|
+
*
|
|
34
|
+
* Silence comes first: a disabled kill switch, an unattended session, or an
|
|
35
|
+
* invalid current version returns `{ show: false }` before any latest-version
|
|
36
|
+
* source is read.
|
|
37
|
+
*
|
|
38
|
+
* Latest-version sources, in order:
|
|
39
|
+
* 1. a valid `BAPI_MCP_UPDATE_CHECK_FAKE_LATEST` override;
|
|
40
|
+
* 2. a reliable, known authoritative latest from a settled live check, which
|
|
41
|
+
* stays authoritative even when it says "current" and an older cache
|
|
42
|
+
* would suggest an update;
|
|
43
|
+
* 3. the local cache, when the authoritative source is absent, still in
|
|
44
|
+
* flight, or failed.
|
|
45
|
+
*
|
|
46
|
+
* The cache ignores its own age on purpose. It is a lower bound: it can
|
|
47
|
+
* under-claim an update but never claims one that does not exist. Unknown,
|
|
48
|
+
* equal, older, or invalid versions are silent.
|
|
49
|
+
*/
|
|
50
|
+
export function getNoticeDecision(env, currentVersion, authoritativeLatest) {
|
|
51
|
+
try {
|
|
52
|
+
if (!parseDefaultOnEnvFlag(env[UPDATE_CHECK_ENABLED_ENV]))
|
|
53
|
+
return SILENT;
|
|
54
|
+
if (isUnattendedSession(env))
|
|
55
|
+
return SILENT;
|
|
56
|
+
if (!isPlainReleaseVersion(currentVersion))
|
|
57
|
+
return SILENT;
|
|
58
|
+
let latest = readFakeLatestOverride(env);
|
|
59
|
+
if (latest === null &&
|
|
60
|
+
authoritativeLatest &&
|
|
61
|
+
authoritativeLatest.known === true &&
|
|
62
|
+
isPlainReleaseVersion(authoritativeLatest.latest)) {
|
|
63
|
+
latest = authoritativeLatest.latest;
|
|
64
|
+
}
|
|
65
|
+
if (latest === null)
|
|
66
|
+
latest = readCachedLatestVersion(env);
|
|
67
|
+
if (!isPlainReleaseVersion(latest))
|
|
68
|
+
return SILENT;
|
|
69
|
+
if (!isNewerVersion(currentVersion, latest))
|
|
70
|
+
return SILENT;
|
|
71
|
+
return { show: true, current: currentVersion, latest };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return SILENT;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Append the session notice to the server's startup instructions when eligible.
|
|
79
|
+
* The base string is returned unchanged when the decision is silent.
|
|
80
|
+
*/
|
|
81
|
+
export function composeServerInstructions(baseInstructions, env, currentVersion) {
|
|
82
|
+
const decision = getNoticeDecision(env, currentVersion);
|
|
83
|
+
if (!decision.show)
|
|
84
|
+
return baseInstructions;
|
|
85
|
+
return `${baseInstructions} ${formatSessionUpgradeNotice(decision.current, decision.latest)}`;
|
|
86
|
+
}
|
package/build/start-tickets.js
CHANGED
|
@@ -63,6 +63,8 @@ import path from "path";
|
|
|
63
63
|
import { fileURLToPath } from "node:url";
|
|
64
64
|
import { validateHttpBaseUrl } from "./base-url.js";
|
|
65
65
|
import { VERSION } from "./version.generated.js";
|
|
66
|
+
import { getNoticeDecision } from "./session-upgrade-notice.js";
|
|
67
|
+
import { formatUpdateAdvice } from "./upgrade-advice.js";
|
|
66
68
|
import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
|
|
67
69
|
import { LEGACY_SERVER_NAMES, MCP_PACKAGE_NAME, MCP_SERVER_NAME } from "./mcp-identity.js";
|
|
68
70
|
// BAPI-941: the shared drift diagnostic formatter. The leaf drift module imports
|
|
@@ -4150,6 +4152,19 @@ export async function runStartTicketsCli(argv, overrides = {}) {
|
|
|
4150
4152
|
const options = parsed.options;
|
|
4151
4153
|
const rawDeps = overrides.deps ?? createDefaultStartTicketsDeps();
|
|
4152
4154
|
const orchestrate = overrides.orchestrate ?? orchestrateStartTickets;
|
|
4155
|
+
// BAPI-1446: one cache-only upgrade reminder per run, on the existing stderr
|
|
4156
|
+
// sink. The decision is evaluated over the EFFECTIVE environment, so a worker's
|
|
4157
|
+
// markers (set when this CLI is itself run by an unattended process) keep it
|
|
4158
|
+
// silent. It reads the local cache only, never the network, and any failure is
|
|
4159
|
+
// contained: it never changes command execution or the exit status.
|
|
4160
|
+
try {
|
|
4161
|
+
const decision = (overrides.upgradeNotice ?? ((env) => getNoticeDecision(env, VERSION)))(rawDeps.env);
|
|
4162
|
+
if (decision.show)
|
|
4163
|
+
errorLog(formatUpdateAdvice(decision.current, decision.latest));
|
|
4164
|
+
}
|
|
4165
|
+
catch {
|
|
4166
|
+
// Advisory only.
|
|
4167
|
+
}
|
|
4153
4168
|
// The parser validates `--agent`, so this resolves to a real spec; the guard
|
|
4154
4169
|
// is defensive only.
|
|
4155
4170
|
const agent = resolveAgentSpec(options.agentName);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-completed-tool-result upgrade notice (BAPI-1446).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the SDK's `tools/call` handler so the first eligible completed result in
|
|
5
|
+
* a stdio session carries one extra text content item naming both versions and
|
|
6
|
+
* `/upgrade-bridge`. Tool execution is untouched: the original handler runs
|
|
7
|
+
* outside the decoration error boundary, so a thrown tool error propagates
|
|
8
|
+
* exactly as it would without this wrapper, and any failure while decorating
|
|
9
|
+
* returns the original result.
|
|
10
|
+
*
|
|
11
|
+
* Eligibility is decided synchronously, after the original handler resolves, by
|
|
12
|
+
* `getNoticeDecision`. It never waits for a background lookup. The once-per-
|
|
13
|
+
* session flag is set only after a successful decoration, so an unknown latest
|
|
14
|
+
* version, a silent decision, or a result that cannot be decorated safely leaves
|
|
15
|
+
* the opportunity for a later call.
|
|
16
|
+
*
|
|
17
|
+
* Like `installToolSurfaceListOverride`, this reads the SDK's private
|
|
18
|
+
* `_requestHandlers` map through one localized compatibility adapter.
|
|
19
|
+
*/
|
|
20
|
+
import { getMethodLiteral } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
|
|
21
|
+
import { getNoticeDecision } from "./session-upgrade-notice.js";
|
|
22
|
+
import { formatSessionUpgradeNotice } from "./upgrade-advice.js";
|
|
23
|
+
/** Fixed diagnostic. It never carries exception text or private map contents. */
|
|
24
|
+
export const TOOL_CALL_ADVISORY_INSTALL_FAILURE = "Bridge MCP upgrade notice: tools/call advisory could not be installed (incompatible MCP SDK).";
|
|
25
|
+
/**
|
|
26
|
+
* Private provenance mark for the genuine, server-sourced `ping` advice item. A
|
|
27
|
+
* symbol-keyed property survives a shallow copy and is ignored by
|
|
28
|
+
* `JSON.stringify`, so it never reaches the wire. The symbol is not exported, so
|
|
29
|
+
* only this module can create or recognize the mark.
|
|
30
|
+
*/
|
|
31
|
+
const SERVER_ADVICE_MARK = Symbol("bridge.serverUpgradeAdvice");
|
|
32
|
+
/** Mark a content item as the genuine server-sourced upgrade advice. Returns it. */
|
|
33
|
+
export function markServerUpgradeAdvice(item) {
|
|
34
|
+
Object.defineProperty(item, SERVER_ADVICE_MARK, {
|
|
35
|
+
value: true,
|
|
36
|
+
enumerable: true,
|
|
37
|
+
configurable: true,
|
|
38
|
+
writable: false,
|
|
39
|
+
});
|
|
40
|
+
return item;
|
|
41
|
+
}
|
|
42
|
+
function isServerUpgradeAdvice(item) {
|
|
43
|
+
return (item !== null &&
|
|
44
|
+
typeof item === "object" &&
|
|
45
|
+
item[SERVER_ADVICE_MARK] === true);
|
|
46
|
+
}
|
|
47
|
+
/** Protocol servers that already carry the wrapper, so reinstall is a no-op. */
|
|
48
|
+
const installedServers = new WeakSet();
|
|
49
|
+
function authoritativeFrom(getStatus) {
|
|
50
|
+
const status = getStatus?.();
|
|
51
|
+
if (status && status.known === true && typeof status.latestVersion === "string") {
|
|
52
|
+
return { known: true, latest: status.latestVersion };
|
|
53
|
+
}
|
|
54
|
+
return { known: false };
|
|
55
|
+
}
|
|
56
|
+
function emit(stderr) {
|
|
57
|
+
try {
|
|
58
|
+
stderr?.(TOOL_CALL_ADVISORY_INSTALL_FAILURE);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// A diagnostic sink failure must never affect startup.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Install the wrapper. Returns true when it is in place (including an earlier
|
|
66
|
+
* install on the same protocol server) and false after emitting the one fixed
|
|
67
|
+
* diagnostic. Never throws, and leaves the original handler in place on failure.
|
|
68
|
+
*/
|
|
69
|
+
export function installToolCallAdvisory(options) {
|
|
70
|
+
const { protocolServer, callSchema, env, currentVersion, getStatus, stderr } = options;
|
|
71
|
+
try {
|
|
72
|
+
if (protocolServer && installedServers.has(protocolServer))
|
|
73
|
+
return true;
|
|
74
|
+
let method;
|
|
75
|
+
try {
|
|
76
|
+
method = getMethodLiteral(callSchema);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
emit(stderr);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (method !== "tools/call") {
|
|
83
|
+
emit(stderr);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const handlers = protocolServer?._requestHandlers;
|
|
87
|
+
if (!handlers || typeof handlers.get !== "function") {
|
|
88
|
+
emit(stderr);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const original = handlers.get(method);
|
|
92
|
+
if (typeof original !== "function") {
|
|
93
|
+
emit(stderr);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
// Delivery state belongs to this installation, so it is isolated to one
|
|
97
|
+
// stdio session.
|
|
98
|
+
let delivered = false;
|
|
99
|
+
const decorate = (result) => {
|
|
100
|
+
if (delivered)
|
|
101
|
+
return result;
|
|
102
|
+
if (result === null || typeof result !== "object")
|
|
103
|
+
return result;
|
|
104
|
+
const content = result.content;
|
|
105
|
+
if (!Array.isArray(content))
|
|
106
|
+
return result;
|
|
107
|
+
const decision = getNoticeDecision(env, currentVersion, authoritativeFrom(getStatus));
|
|
108
|
+
if (!decision.show)
|
|
109
|
+
return result;
|
|
110
|
+
if (content.some(isServerUpgradeAdvice)) {
|
|
111
|
+
// The genuine ping advice already told the session.
|
|
112
|
+
delivered = true;
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
const notice = formatSessionUpgradeNotice(decision.current, decision.latest);
|
|
116
|
+
const decorated = {
|
|
117
|
+
...result,
|
|
118
|
+
content: [...content, { type: "text", text: notice }],
|
|
119
|
+
};
|
|
120
|
+
delivered = true;
|
|
121
|
+
return decorated;
|
|
122
|
+
};
|
|
123
|
+
const wrapped = async (request, extra) => {
|
|
124
|
+
// Outside the boundary below: a tool-call error propagates unchanged.
|
|
125
|
+
const result = await original(request, extra);
|
|
126
|
+
try {
|
|
127
|
+
return decorate(result);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
// Replace in place; never remove the original first.
|
|
135
|
+
protocolServer.setRequestHandler(callSchema, wrapped);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// The replacement may have begun before the failure. Put the original back.
|
|
139
|
+
try {
|
|
140
|
+
if (handlers.get(method) !== original) {
|
|
141
|
+
try {
|
|
142
|
+
protocolServer.setRequestHandler(callSchema, original);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
handlers.set(method, original);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Nothing more can be done safely.
|
|
151
|
+
}
|
|
152
|
+
emit(stderr);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
installedServers.add(protocolServer);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
emit(stderr);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -372,116 +372,55 @@ function setsEqual(a, b) {
|
|
|
372
372
|
return true;
|
|
373
373
|
}
|
|
374
374
|
// ---------------------------------------------------------------------------
|
|
375
|
-
//
|
|
375
|
+
// Asset-recovery advisory composition
|
|
376
376
|
// ---------------------------------------------------------------------------
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
377
|
+
//
|
|
378
|
+
// BAPI-1446 removed the update-staleness decoration (BAPI-728) from `tools/list`:
|
|
379
|
+
// the `_meta` key and the text prepended to the `ping` description were an
|
|
380
|
+
// invisible channel, and the upgrade notice now reaches a session through the
|
|
381
|
+
// startup instructions, the first completed tool result, and the SessionStart
|
|
382
|
+
// hook. Only the asset-recovery advisory decorates the list now.
|
|
383
|
+
/** The tool whose description carries the model-visible recovery advisory. */
|
|
380
384
|
const ADVISORY_HOST_TOOL = "ping";
|
|
381
|
-
/**
|
|
382
|
-
* Wrap a `tools/list` handler so a KNOWN-stale server tells the model so.
|
|
383
|
-
*
|
|
384
|
-
* Why decorate the list at all: the staleness value was already computed on every
|
|
385
|
-
* server start and emitted to an MCP log sink nothing reads, so a session talking
|
|
386
|
-
* to a twenty-release-old build looked completely normal — the visible symptom was
|
|
387
|
-
* an agent that could not find `council`, with no signal explaining why. Tool
|
|
388
|
-
* discovery is the one surface every session reads without being asked to.
|
|
389
|
-
*
|
|
390
|
-
* What is preserved, exactly: tool NAMES, their ORDER, their input/output SCHEMAS,
|
|
391
|
-
* their enabled state, and every other response property. The advisory is added in
|
|
392
|
-
* two places, both additive:
|
|
393
|
-
*
|
|
394
|
-
* - `_meta` — the protocol's sanctioned extension point, for clients that
|
|
395
|
-
* surface structured metadata;
|
|
396
|
-
* - the `ping` tool's DESCRIPTION — the surface a model actually reads. `ping`
|
|
397
|
-
* is the topically correct host: its description already documents the
|
|
398
|
-
* version/upgrade metadata it returns. Note this does not require anyone to
|
|
399
|
-
* CALL `ping` — reading its description during ordinary discovery is enough,
|
|
400
|
-
* which is the precise gap the ticket describes.
|
|
401
|
-
*
|
|
402
|
-
* The description text is appended at RUNTIME only, over the `ping` description
|
|
403
|
-
* from the static tool catalog. That static catalog is untouched, so the
|
|
404
|
-
* description prose pins that guard `mcp_server/src/index.ts` are unaffected —
|
|
405
|
-
* but a LIVE `tools/list` capture DOES include this decoration whenever the
|
|
406
|
-
* update status is stale, and so is NOT automatically safe for tools-budget
|
|
407
|
-
* measurement. Budget measurements stay repository-owned only because their
|
|
408
|
-
* child process explicitly disables the update check via
|
|
409
|
-
* `BAPI_MCP_UPDATE_CHECK_ENABLED=false` (BAPI-917) — see
|
|
410
|
-
* `mcp_server/src/integration/measure-tools.ts`.
|
|
411
|
-
*
|
|
412
|
-
* Fail-open: if the advisory cannot be produced, the original result is returned
|
|
413
|
-
* byte-for-byte.
|
|
414
|
-
*/
|
|
415
|
-
export function decorateListResultWithUpdateAdvisory(original, advisory) {
|
|
416
|
-
if (!advisory)
|
|
417
|
-
return original;
|
|
418
|
-
try {
|
|
419
|
-
const tools = original.tools.map((tool) => tool.name === ADVISORY_HOST_TOOL
|
|
420
|
-
? { ...tool, description: `${advisory}\n\n${tool.description ?? ""}`.trimEnd() }
|
|
421
|
-
: tool);
|
|
422
|
-
return {
|
|
423
|
-
...original,
|
|
424
|
-
tools,
|
|
425
|
-
_meta: { ...(original._meta ?? {}), [UPDATE_ADVISORY_META_KEY]: advisory },
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
catch {
|
|
429
|
-
return original;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Compose an advisory-decorating handler over `inner`.
|
|
434
|
-
*
|
|
435
|
-
* `inner` is the capability gate's handler when dynamic gating is enabled, and
|
|
436
|
-
* the SDK's own captured handler when it is disabled — so turning gating off
|
|
437
|
-
* never turns update visibility off with it.
|
|
438
|
-
*/
|
|
439
|
-
export function createUpdateAdvisoryListHandler(inner, getAdvisory, onListServed) {
|
|
440
|
-
return async (request, extra) => {
|
|
441
|
-
const result = await inner(request, extra);
|
|
442
|
-
try {
|
|
443
|
-
onListServed?.();
|
|
444
|
-
return decorateListResultWithUpdateAdvisory(result, getAdvisory());
|
|
445
|
-
}
|
|
446
|
-
catch {
|
|
447
|
-
// Never let advisory decoration break tool discovery.
|
|
448
|
-
return result;
|
|
449
|
-
}
|
|
450
|
-
};
|
|
451
|
-
}
|
|
452
|
-
/** Compose once over undecorated SDK results, preserving both metadata keys.
|
|
385
|
+
/** Compose once over undecorated SDK results, preserving the recovery metadata.
|
|
453
386
|
* Weak keys prevent retained results from accumulating session-wide state. */
|
|
454
387
|
const undecoratedResults = new WeakMap();
|
|
455
|
-
export function composeListAdvisories(original,
|
|
388
|
+
export function composeListAdvisories(original, recovery) {
|
|
456
389
|
const base = undecoratedResults.get(original) ?? original;
|
|
457
|
-
const updated = decorateListResultWithUpdateAdvisory(base, update);
|
|
458
390
|
if (!recovery)
|
|
459
|
-
return
|
|
391
|
+
return base;
|
|
460
392
|
try {
|
|
461
|
-
const anchor = base.tools.find(t => t.name ===
|
|
462
|
-
const result = { ...
|
|
463
|
-
tools:
|
|
464
|
-
? { ...tool, description: `${
|
|
465
|
-
_meta: { ...(
|
|
393
|
+
const anchor = base.tools.find(t => t.name === ADVISORY_HOST_TOOL) ?? base.tools[0];
|
|
394
|
+
const result = { ...base,
|
|
395
|
+
tools: base.tools.map(tool => tool.name === anchor?.name
|
|
396
|
+
? { ...tool, description: `${recovery.text}\n\n${anchor?.description ?? ""}`.trimEnd() } : tool),
|
|
397
|
+
_meta: { ...(base._meta ?? {}), "bridge-api/asset-recovery": { host: recovery.host,
|
|
466
398
|
missingCount: recovery.missingCount, paths: recovery.paths, capabilities: recovery.capabilities, command: recovery.command } },
|
|
467
399
|
};
|
|
468
400
|
undecoratedResults.set(result, base);
|
|
469
401
|
return result;
|
|
470
402
|
}
|
|
471
403
|
catch {
|
|
472
|
-
return
|
|
404
|
+
return base;
|
|
473
405
|
}
|
|
474
406
|
}
|
|
475
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Compose the recovery advisory over `inner`.
|
|
409
|
+
*
|
|
410
|
+
* `inner` is the capability gate's handler when dynamic gating is enabled, and
|
|
411
|
+
* the SDK's own captured handler when it is disabled — so turning gating off
|
|
412
|
+
* never turns recovery visibility off with it. A failure to retrieve or apply the
|
|
413
|
+
* advisory returns the undecorated original result.
|
|
414
|
+
*/
|
|
415
|
+
export function createComposedAdvisoryListHandler(inner, getRecovery) {
|
|
476
416
|
return async (request, extra) => {
|
|
477
417
|
const original = await inner(request, extra);
|
|
478
418
|
try {
|
|
479
419
|
const recovery = await getRecovery();
|
|
480
|
-
|
|
481
|
-
return composeListAdvisories(original, getUpdate(), recovery);
|
|
420
|
+
return composeListAdvisories(original, recovery);
|
|
482
421
|
}
|
|
483
422
|
catch {
|
|
484
|
-
return
|
|
423
|
+
return original;
|
|
485
424
|
}
|
|
486
425
|
};
|
|
487
426
|
}
|
package/build/update-check.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Update check logic — extracted for testability.
|
|
3
|
-
*
|
|
3
|
+
* `update-status.ts` (the background manager) and `doctor.ts` call checkForUpdate();
|
|
4
|
+
* the synchronous notice paths use `readCachedLatestVersion` and never touch the
|
|
5
|
+
* network.
|
|
4
6
|
*/
|
|
7
|
+
import { readFileSync } from "fs";
|
|
5
8
|
import { writeFile, mkdir, readFile } from "fs/promises";
|
|
6
9
|
import path from "path";
|
|
7
10
|
import os from "os";
|
|
@@ -10,13 +13,51 @@ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
|
10
13
|
const CACHE_TTL = 86400000; // 24 hours
|
|
11
14
|
const FETCH_TIMEOUT = 3000;
|
|
12
15
|
const REGISTRY_URL = `https://registry.npmjs.org/${MCP_PACKAGE_NAME}/latest`;
|
|
16
|
+
/** Manual-only override of the latest version, for exercising the notice paths. */
|
|
17
|
+
export const FAKE_LATEST_ENV = "BAPI_MCP_UPDATE_CHECK_FAKE_LATEST";
|
|
18
|
+
const PLAIN_RELEASE_RE = /^\d+\.\d+\.\d+$/;
|
|
13
19
|
export function getCachePath() {
|
|
14
20
|
return path.join(os.homedir(), ".config", ...MCP_PACKAGE_NAME.split("/"), "update-check.json");
|
|
15
21
|
}
|
|
22
|
+
/** True only for a plain `MAJOR.MINOR.PATCH` release string. */
|
|
23
|
+
export function isPlainReleaseVersion(value) {
|
|
24
|
+
return typeof value === "string" && PLAIN_RELEASE_RE.test(value);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Read the manual fake-latest override. Returns the value only when it is a plain
|
|
28
|
+
* release string; anything else (absent, blank, a prerelease, shell text) is
|
|
29
|
+
* ignored and never echoed.
|
|
30
|
+
*/
|
|
31
|
+
export function readFakeLatestOverride(env = process.env) {
|
|
32
|
+
const value = env[FAKE_LATEST_ENV];
|
|
33
|
+
return isPlainReleaseVersion(value) ? value : null;
|
|
34
|
+
}
|
|
35
|
+
/** Compare two digit strings numerically without converting them to Numbers. */
|
|
36
|
+
function compareDigitStrings(a, b) {
|
|
37
|
+
const x = a.replace(/^0+(?=\d)/, "");
|
|
38
|
+
const y = b.replace(/^0+(?=\d)/, "");
|
|
39
|
+
if (x.length !== y.length)
|
|
40
|
+
return x.length > y.length ? 1 : -1;
|
|
41
|
+
if (x === y)
|
|
42
|
+
return 0;
|
|
43
|
+
return x > y ? 1 : -1;
|
|
44
|
+
}
|
|
16
45
|
/**
|
|
17
46
|
* Compare two semver strings. Returns true if `latest` is strictly greater.
|
|
47
|
+
* Valid release strings are compared segment by segment as digit strings, so a
|
|
48
|
+
* segment beyond the safe-integer range keeps its ordering.
|
|
18
49
|
*/
|
|
19
50
|
export function isNewerVersion(current, latest) {
|
|
51
|
+
if (isPlainReleaseVersion(current) && isPlainReleaseVersion(latest)) {
|
|
52
|
+
const c = current.split(".");
|
|
53
|
+
const l = latest.split(".");
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
const order = compareDigitStrings(l[i], c[i]);
|
|
56
|
+
if (order !== 0)
|
|
57
|
+
return order > 0;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
20
61
|
const c = current.split(".").map(Number);
|
|
21
62
|
const l = latest.split(".").map(Number);
|
|
22
63
|
for (let i = 0; i < 3; i++) {
|
|
@@ -27,28 +68,66 @@ export function isNewerVersion(current, latest) {
|
|
|
27
68
|
}
|
|
28
69
|
return false;
|
|
29
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Read the latest version the server last cached, synchronously and offline.
|
|
73
|
+
*
|
|
74
|
+
* A valid override wins before the cache is opened. The cache's age is ignored on
|
|
75
|
+
* purpose: a cached latest can only be older than the real latest, so it is a
|
|
76
|
+
* lower bound. It may under-claim an update, but it never claims one that does
|
|
77
|
+
* not exist. Any missing, corrupt, malformed, or unreadable cache returns null.
|
|
78
|
+
*/
|
|
79
|
+
export function readCachedLatestVersion(env = process.env) {
|
|
80
|
+
try {
|
|
81
|
+
const override = readFakeLatestOverride(env);
|
|
82
|
+
if (override)
|
|
83
|
+
return override;
|
|
84
|
+
const cache = JSON.parse(readFileSync(getCachePath(), "utf-8"));
|
|
85
|
+
if (cache === null || typeof cache !== "object" || Array.isArray(cache))
|
|
86
|
+
return null;
|
|
87
|
+
const { lastCheck, latestVersion } = cache;
|
|
88
|
+
if (typeof lastCheck !== "number" || !Number.isFinite(lastCheck))
|
|
89
|
+
return null;
|
|
90
|
+
return isPlainReleaseVersion(latestVersion) ? latestVersion : null;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
30
96
|
/**
|
|
31
97
|
* Checks the npm registry for a newer version of @bridge_gpt/mcp-server.
|
|
32
98
|
* Results are cached for 24 hours. Returns null if the check fails.
|
|
33
99
|
*/
|
|
34
|
-
export async function checkForUpdate() {
|
|
100
|
+
export async function checkForUpdate(options = {}) {
|
|
35
101
|
try {
|
|
102
|
+
const override = readFakeLatestOverride(options.env ?? process.env);
|
|
103
|
+
if (override) {
|
|
104
|
+
// An override is never cached, so it cannot outlive the manual test.
|
|
105
|
+
return {
|
|
106
|
+
updateAvailable: isNewerVersion(VERSION, override),
|
|
107
|
+
currentVersion: VERSION,
|
|
108
|
+
latestVersion: override,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const useCache = options.readCache !== false;
|
|
112
|
+
const saveCache = options.writeCache !== false;
|
|
36
113
|
const cachePath = getCachePath();
|
|
37
114
|
const cacheDir = path.dirname(cachePath);
|
|
38
115
|
let latestVersion = null;
|
|
39
116
|
// Try reading cache
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
117
|
+
if (useCache) {
|
|
118
|
+
try {
|
|
119
|
+
const raw = await readFile(cachePath, "utf-8");
|
|
120
|
+
const cache = JSON.parse(raw);
|
|
121
|
+
if (cache &&
|
|
122
|
+
typeof cache.lastCheck === "number" &&
|
|
123
|
+
isPlainReleaseVersion(cache.latestVersion) &&
|
|
124
|
+
Date.now() - cache.lastCheck < CACHE_TTL) {
|
|
125
|
+
latestVersion = cache.latestVersion;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Cache missing, corrupt, or stale — will fetch fresh
|
|
48
130
|
}
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
// Cache missing, corrupt, or stale — will fetch fresh
|
|
52
131
|
}
|
|
53
132
|
// Fetch from registry if no valid cache
|
|
54
133
|
if (!latestVersion) {
|
|
@@ -57,10 +136,12 @@ export async function checkForUpdate() {
|
|
|
57
136
|
try {
|
|
58
137
|
const res = await fetch(REGISTRY_URL, { signal: controller.signal });
|
|
59
138
|
const data = await res.json();
|
|
60
|
-
if (data.version) {
|
|
139
|
+
if (isPlainReleaseVersion(data.version)) {
|
|
61
140
|
latestVersion = data.version;
|
|
62
|
-
|
|
63
|
-
|
|
141
|
+
if (saveCache) {
|
|
142
|
+
await mkdir(cacheDir, { recursive: true });
|
|
143
|
+
await writeFile(cachePath, JSON.stringify({ lastCheck: Date.now(), latestVersion }), "utf-8");
|
|
144
|
+
}
|
|
64
145
|
}
|
|
65
146
|
}
|
|
66
147
|
finally {
|