@bridge_gpt/mcp-server 0.2.34 → 0.2.37
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 +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +32 -13
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +259 -0
- package/build/setup-epic.js +772 -28
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer-owned self-re-exec from `@latest` (BAPI-714, Group B).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. The documented setup command dropped `@latest`
|
|
5
|
+
* (`npx -y @bridge_gpt/mcp-server install`), and an UNPINNED npx spec silently
|
|
6
|
+
* reuses a stale cached copy — the verified bug recorded in `init.ts:32-38`,
|
|
7
|
+
* where 0.2.6 persisted across an upgrade. So the install path must do for itself
|
|
8
|
+
* what `upgrade` already does: look up the latest published version and, when the
|
|
9
|
+
* local copy is strictly older, hand off to `@latest`.
|
|
10
|
+
*
|
|
11
|
+
* THREE PROPERTIES ARE LOAD-BEARING.
|
|
12
|
+
*
|
|
13
|
+
* 1. FAIL OPEN, ALWAYS. `upgrade` can afford to fail; `install` is someone's
|
|
14
|
+
* first contact with Bridge. An unreachable, slow, non-2xx, or malformed
|
|
15
|
+
* registry means "run the local copy" — never an error, prompt, or stall.
|
|
16
|
+
* 2. `stdio: "inherit"` IS NOT STYLISTIC. `promptSecret`/`promptLine`/
|
|
17
|
+
* `promptMultiSelect` are wired only when `process.stdin.isTTY`
|
|
18
|
+
* (`install-bridge.ts:1195`, `:1224-1226`); non-inherited stdio silently
|
|
19
|
+
* strips all three and turns an interactive install into a non-interactive
|
|
20
|
+
* one with no error.
|
|
21
|
+
* 3. THE SENTINEL IS STRIPPED BEFORE *ANY* ARGV INTERPRETATION. Two verified
|
|
22
|
+
* reasons: `parseInstallBridgeArgs` rejects unrecognized arguments, so a
|
|
23
|
+
* leftover `--internal-reexec` is an argument error on the child run; and
|
|
24
|
+
* `install-bridge.ts:1690` computes `isBareInvocation = argv.length === 0`,
|
|
25
|
+
* so a leftover sentinel makes the child take a DIFFERENT onboarding branch
|
|
26
|
+
* silently. Its presence is also the terminating condition, so exactly one
|
|
27
|
+
* re-exec is possible per invocation.
|
|
28
|
+
*
|
|
29
|
+
* SECRET DISCIPLINE. Child argv is process-visible (`ps`), so `--api-key <value>`
|
|
30
|
+
* and a VALUED `--invite <token>` are lifted out of argv and forwarded only
|
|
31
|
+
* through the child ENVIRONMENT as `BAPI_API_KEY` / `BAPI_INVITE`. A value-less
|
|
32
|
+
* `--invite` marker is deliberately RETAINED in child argv: it is what selects
|
|
33
|
+
* bootstrap-invite mode, and dropping it would leave `argv.length === 0` and send
|
|
34
|
+
* the child down the bare-onboarding branch instead. The failure diagnostic is a
|
|
35
|
+
* fixed string — it interpolates no argv, no environment value, and no exception
|
|
36
|
+
* text.
|
|
37
|
+
*/
|
|
38
|
+
import { spawn } from "child_process";
|
|
39
|
+
import { VERSION } from "./version.generated.js";
|
|
40
|
+
import { isNewerVersion } from "./update-check.js";
|
|
41
|
+
import { fetchLatestVersion } from "./cli-release.js";
|
|
42
|
+
import { runInstallBridgeCli } from "./install-bridge.js";
|
|
43
|
+
/** The sentinel that marks an already-re-exec'd child. Matches `upgrade-cli.ts`. */
|
|
44
|
+
export const INSTALL_REEXEC_SENTINEL = "--internal-reexec";
|
|
45
|
+
/**
|
|
46
|
+
* The ONLY text emitted when the hand-off itself fails. Fixed by construction —
|
|
47
|
+
* no argv, no environment value, no raw exception message, no credential.
|
|
48
|
+
*/
|
|
49
|
+
export const INSTALL_REEXEC_FAILURE_MESSAGE = "Bridge API install failed: could not run the latest installer.";
|
|
50
|
+
/**
|
|
51
|
+
* Remove EVERY `--internal-reexec` occurrence and report whether any was present.
|
|
52
|
+
*
|
|
53
|
+
* Called before nested-command detection, before the re-exec decision, and before
|
|
54
|
+
* anything is handed to `runInstallBridgeCli` — the sentinel must never reach the
|
|
55
|
+
* installer's strict argument parser or its bare-invocation computation.
|
|
56
|
+
*/
|
|
57
|
+
export function stripInternalReexecSentinels(argv) {
|
|
58
|
+
const args = [];
|
|
59
|
+
let sentinelPresent = false;
|
|
60
|
+
for (const arg of argv) {
|
|
61
|
+
if (arg === INSTALL_REEXEC_SENTINEL) {
|
|
62
|
+
sentinelPresent = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
args.push(arg);
|
|
66
|
+
}
|
|
67
|
+
return { args, sentinelPresent };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Lift secret-valued flags out of argv and into a child-environment overlay.
|
|
71
|
+
*
|
|
72
|
+
* Mirrors `parseInstallBridgeArgs` token-for-token so the two never disagree:
|
|
73
|
+
*
|
|
74
|
+
* - `--api-key=<value>` / `--api-key <value>` → `BAPI_API_KEY`, flag AND value
|
|
75
|
+
* removed. The installer resolves the key from the environment, and
|
|
76
|
+
* `hasEnvApiKey` keeps the bare-interactive chooser suppressed exactly as the
|
|
77
|
+
* flag did.
|
|
78
|
+
* - `--invite=<token>` / `--invite <token>` → `BAPI_INVITE`, with a VALUE-LESS
|
|
79
|
+
* `--invite` left in argv. That marker still selects bootstrap-invite mode and
|
|
80
|
+
* the installer falls back to `BAPI_INVITE` for the token.
|
|
81
|
+
* - MALFORMED forms (`--api-key` at end of argv, `--api-key=`, `--invite=`) are
|
|
82
|
+
* left untouched so the installer's own parser produces the established
|
|
83
|
+
* argument error rather than this layer inventing a new one.
|
|
84
|
+
*
|
|
85
|
+
* Every other argument is preserved byte-for-byte in its original order. This is
|
|
86
|
+
* deliberately NOT `sanitizePrewarmEnv()` from `install-bridge.ts`: that helper
|
|
87
|
+
* STRIPS installer credentials for a `--version` probe and has the opposite
|
|
88
|
+
* contract from this hand-off.
|
|
89
|
+
*/
|
|
90
|
+
export function prepareInstallReexecArguments(argv) {
|
|
91
|
+
const forwardedArgs = [];
|
|
92
|
+
const secretEnv = {};
|
|
93
|
+
for (let i = 0; i < argv.length; i++) {
|
|
94
|
+
const arg = argv[i];
|
|
95
|
+
if (arg === "--api-key" || arg.startsWith("--api-key=")) {
|
|
96
|
+
let value;
|
|
97
|
+
let consumedNext = false;
|
|
98
|
+
if (arg.startsWith("--api-key=")) {
|
|
99
|
+
value = arg.slice("--api-key=".length);
|
|
100
|
+
}
|
|
101
|
+
else if (i + 1 < argv.length) {
|
|
102
|
+
value = argv[i + 1];
|
|
103
|
+
consumedNext = true;
|
|
104
|
+
}
|
|
105
|
+
// Blank / absent value == malformed: hand it to the installer's parser.
|
|
106
|
+
if (value === undefined || value.length === 0) {
|
|
107
|
+
forwardedArgs.push(arg);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
secretEnv.BAPI_API_KEY = value;
|
|
111
|
+
if (consumedNext)
|
|
112
|
+
i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (arg === "--invite" || arg.startsWith("--invite=")) {
|
|
116
|
+
if (arg.startsWith("--invite=")) {
|
|
117
|
+
const value = arg.slice("--invite=".length);
|
|
118
|
+
if (value.length === 0) {
|
|
119
|
+
// Malformed: leave it for the installer's parser.
|
|
120
|
+
forwardedArgs.push(arg);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
secretEnv.BAPI_INVITE = value;
|
|
124
|
+
forwardedArgs.push("--invite");
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Bare `--invite`: only a following NON-flag token is its value, exactly as
|
|
128
|
+
// `parseInstallBridgeArgs` reads it.
|
|
129
|
+
const next = argv[i + 1];
|
|
130
|
+
forwardedArgs.push("--invite");
|
|
131
|
+
if (typeof next === "string" && !next.startsWith("-") && next.length > 0) {
|
|
132
|
+
secretEnv.BAPI_INVITE = next;
|
|
133
|
+
i += 1;
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
forwardedArgs.push(arg);
|
|
138
|
+
}
|
|
139
|
+
return { forwardedArgs, secretEnv };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The public `install` / legacy `install-bridge` entry point.
|
|
143
|
+
*
|
|
144
|
+
* Fail-open ordering, in the only order that is safe:
|
|
145
|
+
*
|
|
146
|
+
* 1. Strip sentinels (before anything reads argv).
|
|
147
|
+
* 2. If a sentinel was present, or the first remaining token is `conductor`,
|
|
148
|
+
* delegate straight to the local installer — no registry lookup, no spawn.
|
|
149
|
+
* The nested `install conductor` surface is intercepted inside
|
|
150
|
+
* `runInstallBridgeCli`, so it must reach it as `["conductor", ...]` rather
|
|
151
|
+
* than being collapsed into bare onboarding.
|
|
152
|
+
* 3. Otherwise look up the latest version, defaulting the comparison target to
|
|
153
|
+
* the local `VERSION` so an unusable answer changes nothing.
|
|
154
|
+
* 4. Re-exec only for a STRICTLY newer published version.
|
|
155
|
+
*/
|
|
156
|
+
export async function runInstallBridgeWithLatestCli(argv, deps = {}) {
|
|
157
|
+
const resolveLatest = deps.fetchLatestVersion ?? (() => fetchLatestVersion());
|
|
158
|
+
const runLocal = deps.runInstallBridgeCli ?? runInstallBridgeCli;
|
|
159
|
+
const doSpawn = deps.spawn ?? spawn;
|
|
160
|
+
const env = deps.env ?? process.env;
|
|
161
|
+
const platform = deps.platform ?? process.platform;
|
|
162
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
163
|
+
const errorLog = deps.errorLog ?? ((message) => console.error(message));
|
|
164
|
+
const localVersion = deps.localVersion ?? VERSION;
|
|
165
|
+
const { args: cleanedArgs, sentinelPresent } = stripInternalReexecSentinels(argv);
|
|
166
|
+
// Terminating condition, and the nested-conductor bypass. Both skip the
|
|
167
|
+
// registry entirely.
|
|
168
|
+
if (sentinelPresent || cleanedArgs[0] === "conductor") {
|
|
169
|
+
return runLocal(cleanedArgs);
|
|
170
|
+
}
|
|
171
|
+
// Default the comparison target to the local version: an unusable registry
|
|
172
|
+
// answer must be indistinguishable from "already current".
|
|
173
|
+
let latestVersion = localVersion;
|
|
174
|
+
let fetched = null;
|
|
175
|
+
try {
|
|
176
|
+
fetched = await resolveLatest();
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
fetched = null;
|
|
180
|
+
}
|
|
181
|
+
if (fetched)
|
|
182
|
+
latestVersion = fetched;
|
|
183
|
+
if (!isNewerVersion(localVersion, latestVersion)) {
|
|
184
|
+
return runLocal(cleanedArgs);
|
|
185
|
+
}
|
|
186
|
+
const { forwardedArgs, secretEnv } = prepareInstallReexecArguments(cleanedArgs);
|
|
187
|
+
const npxCmd = platform === "win32" ? "npx.cmd" : "npx";
|
|
188
|
+
const childArgs = [
|
|
189
|
+
"-y",
|
|
190
|
+
"@bridge_gpt/mcp-server@latest",
|
|
191
|
+
"install",
|
|
192
|
+
INSTALL_REEXEC_SENTINEL,
|
|
193
|
+
...forwardedArgs,
|
|
194
|
+
];
|
|
195
|
+
return new Promise((resolve) => {
|
|
196
|
+
let settled = false;
|
|
197
|
+
const settle = (code) => {
|
|
198
|
+
if (settled)
|
|
199
|
+
return;
|
|
200
|
+
settled = true;
|
|
201
|
+
resolve(code);
|
|
202
|
+
};
|
|
203
|
+
const fail = () => {
|
|
204
|
+
errorLog(INSTALL_REEXEC_FAILURE_MESSAGE);
|
|
205
|
+
settle(1);
|
|
206
|
+
};
|
|
207
|
+
let child;
|
|
208
|
+
try {
|
|
209
|
+
child = doSpawn(npxCmd, childArgs, {
|
|
210
|
+
shell: false,
|
|
211
|
+
stdio: "inherit",
|
|
212
|
+
cwd,
|
|
213
|
+
// Explicit CLI values win over an inherited value of the same key.
|
|
214
|
+
env: { ...env, ...secretEnv },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
fail();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
child.on("close", (code) => {
|
|
222
|
+
// A signal termination or an absent numeric code is an abnormal exit.
|
|
223
|
+
if (typeof code === "number") {
|
|
224
|
+
settle(code);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
fail();
|
|
228
|
+
});
|
|
229
|
+
child.on("error", () => {
|
|
230
|
+
fail();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, read-only inspection of every project-local MCP launcher config
|
|
3
|
+
* (BAPI-728).
|
|
4
|
+
*
|
|
5
|
+
* Before this module, three callers each carried their own idea of what a
|
|
6
|
+
* launcher config looks like:
|
|
7
|
+
*
|
|
8
|
+
* - `doctor.ts` parsed the pin correctly and reported `STALE-PINNED`.
|
|
9
|
+
* - `upgrade-cli.ts` kept a duplicate path list and never read the pin at all,
|
|
10
|
+
* deciding from the running process `VERSION` instead — which is why a
|
|
11
|
+
* twenty-release repin reported itself as `0.2.36 -> 0.2.36`.
|
|
12
|
+
* - `init.ts` repaired the pin as an undocumented side effect of writing.
|
|
13
|
+
*
|
|
14
|
+
* This module is the single authority for *what a config currently says*. It is
|
|
15
|
+
* strictly read-only: it never writes, never spawns, never touches the network,
|
|
16
|
+
* and never resolves credentials. Callers layer their own concerns on top —
|
|
17
|
+
* `doctor` adds a read-only npx cache probe, `init` performs the mutation,
|
|
18
|
+
* `upgrade` renders the transition report and owns exit status.
|
|
19
|
+
*
|
|
20
|
+
* Secret safety: an inspection result carries only sanitized launcher facts —
|
|
21
|
+
* relative path, launcher shape, the package spec token, and versions. It never
|
|
22
|
+
* carries config bodies, `env` maps, credential values, or raw error text.
|
|
23
|
+
*/
|
|
24
|
+
import path from "path";
|
|
25
|
+
/** The npm package every Bridge launcher spec names. */
|
|
26
|
+
export const BRIDGE_PACKAGE_NAME = "@bridge_gpt/mcp-server";
|
|
27
|
+
/**
|
|
28
|
+
* The subcommand token that marks a worktree shim registration. A shim points at
|
|
29
|
+
* a sibling worktree's server rather than a published package, so no pin applies
|
|
30
|
+
* and no rewrite is ever safe (BAPI-714 / worktree provisioning).
|
|
31
|
+
*/
|
|
32
|
+
const WORKTREE_SHIM_TOKEN = "mcp-invoke";
|
|
33
|
+
/** The MCP server entry key every Bridge registration uses. */
|
|
34
|
+
export const BRIDGE_ENTRY_KEY = "bridge-api";
|
|
35
|
+
/**
|
|
36
|
+
* Every project-local MCP config Bridge registers into. Exported so `doctor`,
|
|
37
|
+
* `init`, and `upgrade` can no longer drift apart on which paths exist or which
|
|
38
|
+
* root key each one uses. Callers that need a different *order* should map over
|
|
39
|
+
* their own list and use {@link launcherTargetFor} for the metadata.
|
|
40
|
+
*/
|
|
41
|
+
export const LAUNCHER_CONFIG_TARGETS = [
|
|
42
|
+
{ relPath: ".mcp.json", topLevelKey: "mcpServers" },
|
|
43
|
+
{ relPath: ".cursor/mcp.json", topLevelKey: "mcpServers" },
|
|
44
|
+
{ relPath: ".vscode/mcp.json", topLevelKey: "servers" },
|
|
45
|
+
];
|
|
46
|
+
/** Look up a target's metadata by relative path, or `undefined` if unknown. */
|
|
47
|
+
export function launcherTargetFor(relPath) {
|
|
48
|
+
return LAUNCHER_CONFIG_TARGETS.find((t) => t.relPath === relPath);
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Exact-version parsing and comparison
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Strict exact-release form: `MAJOR.MINOR.PATCH`, nothing else. Deliberately
|
|
55
|
+
* narrower than semver at large — a prerelease, build metadata, a `v` prefix, a
|
|
56
|
+
* two-part `0.2`, a range (`^0.2.0`), or a dist-tag (`beta`) is NOT an exact
|
|
57
|
+
* version and must never be treated as one. Everything the package publishes
|
|
58
|
+
* takes this form, so anything else in a config is something a human wrote and
|
|
59
|
+
* an automated repin has no business silently rewriting.
|
|
60
|
+
*/
|
|
61
|
+
const EXACT_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
62
|
+
/** Parse an exact `MAJOR.MINOR.PATCH` string, or return null for any other form. */
|
|
63
|
+
export function parseExactVersion(raw) {
|
|
64
|
+
if (typeof raw !== "string")
|
|
65
|
+
return null;
|
|
66
|
+
const match = EXACT_VERSION_RE.exec(raw.trim());
|
|
67
|
+
if (!match)
|
|
68
|
+
return null;
|
|
69
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
70
|
+
}
|
|
71
|
+
/** True when `value` is an exact `MAJOR.MINOR.PATCH` release string. */
|
|
72
|
+
export function isExactVersion(raw) {
|
|
73
|
+
return parseExactVersion(raw) !== null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Compare two exact versions: negative when `a < b`, zero when equal, positive
|
|
77
|
+
* when `a > b`. Returns null when either side is not an exact version, so a
|
|
78
|
+
* caller can never accidentally order a range against a release.
|
|
79
|
+
*/
|
|
80
|
+
export function compareExactVersions(a, b) {
|
|
81
|
+
const left = parseExactVersion(a);
|
|
82
|
+
const right = parseExactVersion(b);
|
|
83
|
+
if (!left || !right)
|
|
84
|
+
return null;
|
|
85
|
+
for (let i = 0; i < 3; i++) {
|
|
86
|
+
if (left[i] !== right[i])
|
|
87
|
+
return left[i] - right[i];
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Extract the Bridge package token from a launcher args array.
|
|
93
|
+
*
|
|
94
|
+
* The package name is itself scoped (`@scope/name`), so the version delimiter is
|
|
95
|
+
* the FIRST `@` *after* the package name — not the last `@` in the string.
|
|
96
|
+
* `@bridge_gpt/mcp-server` is bare; `@bridge_gpt/mcp-server@0.2.16` pins
|
|
97
|
+
* `0.2.16`. A non-Bridge package token is never matched.
|
|
98
|
+
*/
|
|
99
|
+
export function parseLauncherPin(args) {
|
|
100
|
+
if (!Array.isArray(args))
|
|
101
|
+
return null;
|
|
102
|
+
for (const arg of args) {
|
|
103
|
+
if (typeof arg !== "string")
|
|
104
|
+
continue;
|
|
105
|
+
if (arg === BRIDGE_PACKAGE_NAME) {
|
|
106
|
+
return { spec: arg, specifier: null, exactVersion: null };
|
|
107
|
+
}
|
|
108
|
+
if (arg.startsWith(`${BRIDGE_PACKAGE_NAME}@`)) {
|
|
109
|
+
const specifier = arg.slice(BRIDGE_PACKAGE_NAME.length + 1).trim();
|
|
110
|
+
if (specifier.length === 0) {
|
|
111
|
+
return { spec: arg, specifier: null, exactVersion: null };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
spec: arg,
|
|
115
|
+
specifier,
|
|
116
|
+
exactVersion: isExactVersion(specifier) ? specifier : null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** True when the args array carries the worktree-shim subcommand token. */
|
|
123
|
+
export function isWorktreeShimArgs(args) {
|
|
124
|
+
return Array.isArray(args) && args.some((a) => a === WORKTREE_SHIM_TOKEN);
|
|
125
|
+
}
|
|
126
|
+
/** Human-readable text for each reason code (no dynamic content). */
|
|
127
|
+
const REASON_TEXT = {
|
|
128
|
+
"file-unreadable": "the file exists but could not be read",
|
|
129
|
+
"invalid-json": "the file is not valid JSON",
|
|
130
|
+
"root-not-object": "the JSON root is not an object",
|
|
131
|
+
"servers-not-object": "the servers section is not an object",
|
|
132
|
+
"entry-not-object": "the bridge-api entry is not an object",
|
|
133
|
+
"no-package-token": "the bridge-api launcher has no recognizable @bridge_gpt/mcp-server token",
|
|
134
|
+
"version-range": "the launcher pin is a version range, which an automated repin must not rewrite",
|
|
135
|
+
"non-release-specifier": "the launcher pin is a dist-tag or channel, not an exact release",
|
|
136
|
+
"malformed-version": "the launcher pin is not a valid MAJOR.MINOR.PATCH release",
|
|
137
|
+
"pinned-ahead-of-target": "the launcher pin is newer than the target version and must not be downgraded",
|
|
138
|
+
};
|
|
139
|
+
/** Render a reason code as sanitized prose. */
|
|
140
|
+
export function describeLauncherReason(reason) {
|
|
141
|
+
return REASON_TEXT[reason];
|
|
142
|
+
}
|
|
143
|
+
/** True for the two actions that must abort a mutation run before any write. */
|
|
144
|
+
export function isPrerequisiteFailure(inspection) {
|
|
145
|
+
return inspection.action === "invalid" || inspection.action === "unsupported";
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* True when this target carries a standard launcher whose pin participates in
|
|
149
|
+
* the "is everything already current?" question. Worktree shims, inactive
|
|
150
|
+
* targets, and absent files deliberately do not.
|
|
151
|
+
*/
|
|
152
|
+
export function isApplicableStandardLauncher(inspection) {
|
|
153
|
+
return (inspection.shape === "standard" &&
|
|
154
|
+
(inspection.action === "repin" ||
|
|
155
|
+
inspection.action === "already-current" ||
|
|
156
|
+
inspection.action === "normalize-unpinned"));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Inspect every project-local MCP config's `bridge-api` launcher, read-only.
|
|
160
|
+
*
|
|
161
|
+
* Every target always produces exactly one result, so a caller can render one
|
|
162
|
+
* line per config without re-deriving which paths it looked at. Only an `ENOENT`
|
|
163
|
+
* read is treated as absence — a permission error on an existing file is an
|
|
164
|
+
* `invalid` prerequisite failure, never a "missing file we can freshly create".
|
|
165
|
+
*/
|
|
166
|
+
export async function inspectLauncherConfigs(options) {
|
|
167
|
+
const targets = options.targets ?? LAUNCHER_CONFIG_TARGETS;
|
|
168
|
+
const results = [];
|
|
169
|
+
for (const target of targets) {
|
|
170
|
+
results.push(await inspectOne(target, options));
|
|
171
|
+
}
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
async function inspectOne(target, options) {
|
|
175
|
+
const { targetVersion } = options;
|
|
176
|
+
const base = {
|
|
177
|
+
relPath: target.relPath,
|
|
178
|
+
topLevelKey: target.topLevelKey,
|
|
179
|
+
targetVersion,
|
|
180
|
+
spec: null,
|
|
181
|
+
currentVersion: null,
|
|
182
|
+
};
|
|
183
|
+
let raw;
|
|
184
|
+
try {
|
|
185
|
+
raw = await options.readFile(path.join(options.cwd, target.relPath));
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const code = err && typeof err === "object" ? err.code : undefined;
|
|
189
|
+
if (code === "ENOENT") {
|
|
190
|
+
const creatable = options.isCreatable?.(target.relPath) ?? false;
|
|
191
|
+
return {
|
|
192
|
+
...base,
|
|
193
|
+
filePresent: false,
|
|
194
|
+
bridgeEntryPresent: false,
|
|
195
|
+
shape: "none",
|
|
196
|
+
action: creatable ? "create" : "skip-inactive",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// Present-but-unreadable is NOT absence. Creating a fresh file over a config
|
|
200
|
+
// we merely failed to read would silently destroy a working registration.
|
|
201
|
+
return {
|
|
202
|
+
...base,
|
|
203
|
+
filePresent: true,
|
|
204
|
+
bridgeEntryPresent: false,
|
|
205
|
+
shape: "none",
|
|
206
|
+
action: "invalid",
|
|
207
|
+
reason: "file-unreadable",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(raw);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
filePresent: true,
|
|
218
|
+
bridgeEntryPresent: false,
|
|
219
|
+
shape: "none",
|
|
220
|
+
action: "invalid",
|
|
221
|
+
reason: "invalid-json",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (!isPlainObject(parsed)) {
|
|
225
|
+
return {
|
|
226
|
+
...base,
|
|
227
|
+
filePresent: true,
|
|
228
|
+
bridgeEntryPresent: false,
|
|
229
|
+
shape: "none",
|
|
230
|
+
action: "invalid",
|
|
231
|
+
reason: "root-not-object",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const servers = parsed[target.topLevelKey];
|
|
235
|
+
if (servers !== undefined && !isPlainObject(servers)) {
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
filePresent: true,
|
|
239
|
+
bridgeEntryPresent: false,
|
|
240
|
+
shape: "none",
|
|
241
|
+
action: "invalid",
|
|
242
|
+
reason: "servers-not-object",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const entry = isPlainObject(servers)
|
|
246
|
+
? servers[BRIDGE_ENTRY_KEY]
|
|
247
|
+
: undefined;
|
|
248
|
+
if (entry === undefined) {
|
|
249
|
+
// A valid host config that simply has no Bridge registration yet. Other
|
|
250
|
+
// servers in the same file are deliberately not inspected or reported.
|
|
251
|
+
return {
|
|
252
|
+
...base,
|
|
253
|
+
filePresent: true,
|
|
254
|
+
bridgeEntryPresent: false,
|
|
255
|
+
shape: "none",
|
|
256
|
+
action: "add",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (!isPlainObject(entry)) {
|
|
260
|
+
return {
|
|
261
|
+
...base,
|
|
262
|
+
filePresent: true,
|
|
263
|
+
bridgeEntryPresent: true,
|
|
264
|
+
shape: "custom",
|
|
265
|
+
action: "invalid",
|
|
266
|
+
reason: "entry-not-object",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const args = entry.args;
|
|
270
|
+
// Worktree shims are detected BEFORE any package-token requirement: the
|
|
271
|
+
// absolute-build-path form (`node /abs/path/build/index.js mcp-invoke`) has no
|
|
272
|
+
// package token at all, and must not be misread as an unsupported launcher.
|
|
273
|
+
if (isWorktreeShimArgs(args)) {
|
|
274
|
+
return {
|
|
275
|
+
...base,
|
|
276
|
+
filePresent: true,
|
|
277
|
+
bridgeEntryPresent: true,
|
|
278
|
+
shape: "worktree-shim",
|
|
279
|
+
action: "skip-worktree-shim",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const pin = parseLauncherPin(args);
|
|
283
|
+
if (!pin) {
|
|
284
|
+
return {
|
|
285
|
+
...base,
|
|
286
|
+
filePresent: true,
|
|
287
|
+
bridgeEntryPresent: true,
|
|
288
|
+
shape: "custom",
|
|
289
|
+
action: "unsupported",
|
|
290
|
+
reason: "no-package-token",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const common = {
|
|
294
|
+
...base,
|
|
295
|
+
filePresent: true,
|
|
296
|
+
bridgeEntryPresent: true,
|
|
297
|
+
shape: "standard",
|
|
298
|
+
spec: pin.spec,
|
|
299
|
+
};
|
|
300
|
+
// Bare and explicit `@latest` are the two forms an exact-pin policy normalizes
|
|
301
|
+
// rather than rejects — both mean "whatever npx resolves", which is exactly the
|
|
302
|
+
// non-determinism BAPI-429 pinned away from.
|
|
303
|
+
if (pin.specifier === null || pin.specifier === "latest") {
|
|
304
|
+
return { ...common, action: "normalize-unpinned" };
|
|
305
|
+
}
|
|
306
|
+
if (!isExactVersion(pin.specifier)) {
|
|
307
|
+
return {
|
|
308
|
+
...common,
|
|
309
|
+
action: "unsupported",
|
|
310
|
+
reason: classifyNonExactSpecifier(pin.specifier),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const comparison = compareExactVersions(pin.specifier, targetVersion);
|
|
314
|
+
if (comparison === null) {
|
|
315
|
+
// The TARGET is not an exact release. Refuse rather than guess.
|
|
316
|
+
return {
|
|
317
|
+
...common,
|
|
318
|
+
currentVersion: pin.specifier,
|
|
319
|
+
action: "unsupported",
|
|
320
|
+
reason: "malformed-version",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (comparison > 0) {
|
|
324
|
+
return {
|
|
325
|
+
...common,
|
|
326
|
+
currentVersion: pin.specifier,
|
|
327
|
+
action: "ahead",
|
|
328
|
+
reason: "pinned-ahead-of-target",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
...common,
|
|
333
|
+
currentVersion: pin.specifier,
|
|
334
|
+
action: comparison === 0 ? "already-current" : "repin",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/** Classify why a non-exact specifier cannot be treated as a release pin. */
|
|
338
|
+
function classifyNonExactSpecifier(specifier) {
|
|
339
|
+
if (/^[\^~]|^[<>=]/.test(specifier) || specifier.includes(" - ") || specifier.includes("||")) {
|
|
340
|
+
return "version-range";
|
|
341
|
+
}
|
|
342
|
+
// A leading digit means the author meant a version and got the shape wrong
|
|
343
|
+
// (`0.2`, `1.2.3.4`); anything else is a dist-tag or channel (`beta`, `next`).
|
|
344
|
+
if (/^v?\d/.test(specifier))
|
|
345
|
+
return "malformed-version";
|
|
346
|
+
return "non-release-specifier";
|
|
347
|
+
}
|
|
348
|
+
/** True for a non-null, non-array object. */
|
|
349
|
+
function isPlainObject(value) {
|
|
350
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
351
|
+
}
|
package/build/mcp-host-config.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* rather than reserialize unrelated content.
|
|
29
29
|
*/
|
|
30
30
|
import { VERSION } from "./version.generated.js";
|
|
31
|
+
import { refreshBridgeApiPackageSpec } from "./init.js";
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
// Path helpers (POSIX-normalized; module stays free of node:path).
|
|
33
34
|
// ---------------------------------------------------------------------------
|
|
@@ -119,7 +120,16 @@ export function mergeJsonHostConfig(existing, target, adaptedEntry) {
|
|
|
119
120
|
const root = rootRaw && typeof rootRaw === "object" && !Array.isArray(rootRaw)
|
|
120
121
|
? { ...rootRaw }
|
|
121
122
|
: {};
|
|
122
|
-
|
|
123
|
+
// BAPI-714 (Group C): an UPDATE to a pre-existing Bridge registration keeps that
|
|
124
|
+
// registration's own launcher args composition — only the package-spec token is
|
|
125
|
+
// refreshed — so a global/manual host config written before `serve` existed is
|
|
126
|
+
// never migrated to it. A registration this path CREATES receives the adapted
|
|
127
|
+
// template args, `serve` included.
|
|
128
|
+
const prior = root["bridge-api"];
|
|
129
|
+
const preservedArgs = prior && typeof prior === "object" && !Array.isArray(prior)
|
|
130
|
+
? refreshBridgeApiPackageSpec(prior.args, currentLauncherSpec())
|
|
131
|
+
: null;
|
|
132
|
+
root["bridge-api"] = preservedArgs ? { ...adaptedEntry, args: preservedArgs } : adaptedEntry;
|
|
123
133
|
merged[target.topLevelKey] = root;
|
|
124
134
|
return merged;
|
|
125
135
|
}
|
|
@@ -152,6 +152,10 @@ export async function writeMcpInstallState(cwd, input, deps) {
|
|
|
152
152
|
projectConfigPaths: normalizeProjectPaths(input.projectConfigPaths),
|
|
153
153
|
};
|
|
154
154
|
const finalPath = installStatePath(cwd);
|
|
155
|
+
return persistMcpInstallState(cwd, state, finalPath, deps);
|
|
156
|
+
}
|
|
157
|
+
/** Shared serialize-and-atomically-persist tail for the two writers below. */
|
|
158
|
+
async function persistMcpInstallState(cwd, state, finalPath, deps) {
|
|
155
159
|
const tempPath = installStateTempPath(cwd);
|
|
156
160
|
const serialized = serializeMcpInstallState(state);
|
|
157
161
|
try {
|
|
@@ -173,3 +177,31 @@ export async function writeMcpInstallState(cwd, input, deps) {
|
|
|
173
177
|
}
|
|
174
178
|
return { ok: true, path: finalPath, state };
|
|
175
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Record a locally installed project artifact WITHOUT clobbering existing state.
|
|
182
|
+
*
|
|
183
|
+
* `writeMcpInstallState` rebuilds the whole document from its `input` — it does
|
|
184
|
+
* not merge. A caller supplying only `projectConfigPaths` would therefore write
|
|
185
|
+
* an EMPTY `selectedPlatforms`, silently dropping the platform roster a later
|
|
186
|
+
* `doctor` run reports from (BAPI-679 review, E-13). This helper reads the
|
|
187
|
+
* current state first and writes back the union, so the additive record stays
|
|
188
|
+
* additive.
|
|
189
|
+
*
|
|
190
|
+
* Only the repo-relative PATH is stored. No OAuth token, API key, GitHub
|
|
191
|
+
* credential, or expanded workflow secret can reach the file: the state schema
|
|
192
|
+
* has no field for one, and the serializer emits only schema-approved keys.
|
|
193
|
+
*/
|
|
194
|
+
export async function recordInstalledProjectArtifact(cwd, relPath, deps) {
|
|
195
|
+
if (typeof relPath !== "string" || relPath.length === 0) {
|
|
196
|
+
return { ok: false, error: "artifact path must be a non-empty relative path" };
|
|
197
|
+
}
|
|
198
|
+
const existing = await readMcpInstallState(cwd, deps);
|
|
199
|
+
const priorPlatforms = existing.status === "valid" ? existing.state.selectedPlatforms : [];
|
|
200
|
+
const priorPaths = existing.status === "valid" ? existing.state.projectConfigPaths : [];
|
|
201
|
+
const state = {
|
|
202
|
+
version: MCP_INSTALL_STATE_VERSION,
|
|
203
|
+
selectedPlatforms: normalizePlatforms(priorPlatforms),
|
|
204
|
+
projectConfigPaths: normalizeProjectPaths([...priorPaths, relPath]),
|
|
205
|
+
};
|
|
206
|
+
return persistMcpInstallState(cwd, state, installStatePath(cwd), deps);
|
|
207
|
+
}
|