@bridge_gpt/mcp-server 0.2.36 → 0.2.38
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 +48 -8
- 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-user-config-doctor.js +317 -0
- package/build/commands.generated.js +2 -1
- package/build/conductor/bridge-api-client.js +178 -4
- package/build/conductor-bin.js +1 -1
- package/build/conductor-bundle-artifacts.js +7 -6
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +239 -80
- package/build/executor/cli.js +51 -1
- package/build/executor/credentials.js +1 -7
- package/build/executor/deps.js +18 -1
- package/build/executor/env.js +51 -25
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +422 -22
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +104 -8
- package/build/executor/preflight.js +32 -0
- package/build/executor/runner.js +8 -0
- package/build/executor/test-clock.js +67 -3
- package/build/executor/types.js +4 -1
- package/build/executor/worker-command.js +11 -3
- 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 +614 -244
- package/build/init.js +363 -73
- package/build/install-bridge.js +568 -80
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +30 -7
- 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 +22 -9
- 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 +2 -0
- package/build/setup-epic.js +662 -27
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +70 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +185 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +57 -34
- package/build/sfcc/reads-site-preference.js +86 -33
- package/build/sfcc/reads-system-object.js +50 -38
- 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 +71 -15
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-custom-object-def.js +6 -2
- 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 +417 -101
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +23 -8
- package/package.json +3 -3
- package/pipelines/implement-ticket.json +15 -5
|
@@ -18,6 +18,7 @@ import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./creden
|
|
|
18
18
|
import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
|
|
19
19
|
import { probeWorktreeMcpRegistration } from "./mcp-registration-doctor.js";
|
|
20
20
|
import { probeWorktreeCommandAssets } from "./command-assets-doctor.js";
|
|
21
|
+
import { CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV, formatClaudeMcpShadowFinding, formatClaudeUserConfigDiagnostic, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "./claude-user-config-doctor.js";
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Constants (moved here from start-tickets.ts so both consumers share them)
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
@@ -480,6 +481,126 @@ export function worktreeCommandAssetsDescriptor() {
|
|
|
480
481
|
},
|
|
481
482
|
};
|
|
482
483
|
}
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// BAPI-727 Claude user-configuration MCP shadowing. Doctor-only and strictly
|
|
486
|
+
// read-only: it reads `~/.claude.json` and runs `git worktree list --porcelain`
|
|
487
|
+
// (a non-mutating query) to learn which checkout is the MAIN worktree, then
|
|
488
|
+
// reports whether a foreign `bridge-api` registration could shadow the
|
|
489
|
+
// provisioning a conductor worker depends on.
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
/** Stable descriptor id — pinned by tests and by the doctor render. */
|
|
492
|
+
export const CLAUDE_MCP_SHADOWING_ID = "claude-mcp-shadowing";
|
|
493
|
+
/**
|
|
494
|
+
* Read-only resolution of the MAIN worktree path via
|
|
495
|
+
* `git worktree list --porcelain` under `deps.cwd`. The porcelain format lists
|
|
496
|
+
* the main checkout FIRST, one record per blank-line-separated block whose first
|
|
497
|
+
* line is `worktree <path>`.
|
|
498
|
+
*
|
|
499
|
+
* Degrades to `null` on a non-zero exit, a thrown runner, or malformed/empty
|
|
500
|
+
* output — never throws, and never retains Git's stderr or an exception message.
|
|
501
|
+
* A null result means "the main path is unknown", which the descriptor reports as
|
|
502
|
+
* degraded uncertainty rather than as a collision.
|
|
503
|
+
*/
|
|
504
|
+
export async function resolveMainWorktreePathForDoctor(deps) {
|
|
505
|
+
let result;
|
|
506
|
+
try {
|
|
507
|
+
result = await deps.runCommand("git", ["worktree", "list", "--porcelain"], {
|
|
508
|
+
cwd: deps.cwd,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
if (!commandSucceeded(result))
|
|
515
|
+
return null;
|
|
516
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
517
|
+
if (!line.startsWith("worktree "))
|
|
518
|
+
continue;
|
|
519
|
+
const candidate = line.slice("worktree ".length).trim();
|
|
520
|
+
return candidate.length > 0 ? candidate : null;
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Secret-free remediation hint shared by the Claude MCP shadowing descriptor.
|
|
526
|
+
*
|
|
527
|
+
* WORDING CONSTRAINT: this module is guarded by a static source scan
|
|
528
|
+
* (`start-tickets-credential-invariants.static.test.ts`) proving the prereqs
|
|
529
|
+
* module performs no filesystem writes. It rejects the bare filesystem-mutation
|
|
530
|
+
* identifiers ANYWHERE in the source — including comments and user-facing prose,
|
|
531
|
+
* which is why the advice below is phrased as "delete, or move aside". Keep every
|
|
532
|
+
* such identifier out of this file, even inside a comment like this one.
|
|
533
|
+
*/
|
|
534
|
+
const CLAUDE_MCP_SHADOWING_HINT = "Delete, or move aside, the 'bridge-api' MCP registration in ~/.claude.json (at user scope, " +
|
|
535
|
+
"or under the projects entry for this repository/worktree) so the registration the " +
|
|
536
|
+
"conductor provisions into each worker worktree is the one that takes effect. If the " +
|
|
537
|
+
"collision is deliberate, set " +
|
|
538
|
+
`${CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV}=1 to override the executor's refusal ` +
|
|
539
|
+
"(doctor still reports it).";
|
|
540
|
+
const CLAUDE_MCP_SHADOWING_INSTALL_HINTS = {
|
|
541
|
+
darwin: CLAUDE_MCP_SHADOWING_HINT,
|
|
542
|
+
linux: CLAUDE_MCP_SHADOWING_HINT,
|
|
543
|
+
win32: CLAUDE_MCP_SHADOWING_HINT,
|
|
544
|
+
};
|
|
545
|
+
/**
|
|
546
|
+
* Doctor-only, strictly read-only probe: does the machine's Claude user
|
|
547
|
+
* configuration register a `bridge-api` MCP server that could shadow a worker's
|
|
548
|
+
* provisioned registration?
|
|
549
|
+
*
|
|
550
|
+
* Status mapping:
|
|
551
|
+
* - no registration, or only acceptable provisioned shims → `found: true`
|
|
552
|
+
* - a CONFIRMED foreign registration → `found: false`
|
|
553
|
+
* - unreadable / malformed config, or an unresolved main
|
|
554
|
+
* worktree path → `found: true` + detail
|
|
555
|
+
*
|
|
556
|
+
* The failing case is deliberately independent of
|
|
557
|
+
* `BAPI_CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING`: the override changes what the
|
|
558
|
+
* EXECUTOR does at runtime, not whether the collision exists. A diagnostic that
|
|
559
|
+
* went quiet because an override happened to be exported would hide the exact
|
|
560
|
+
* condition an operator ran doctor to find.
|
|
561
|
+
*/
|
|
562
|
+
export function claudeMcpShadowingDescriptor() {
|
|
563
|
+
return {
|
|
564
|
+
id: CLAUDE_MCP_SHADOWING_ID,
|
|
565
|
+
label: "Claude user-config MCP shadowing (~/.claude.json)",
|
|
566
|
+
installHint: CLAUDE_MCP_SHADOWING_INSTALL_HINTS,
|
|
567
|
+
// The detail below is built exclusively from the inspector's safe metadata
|
|
568
|
+
// (scope labels, the config path, a fixed shape label, sorted env KEY names),
|
|
569
|
+
// so it is safe to render even when this prerequisite reads as missing.
|
|
570
|
+
renderDetailWhenMissing: true,
|
|
571
|
+
probe: async (deps) => {
|
|
572
|
+
const { readFile, homedir } = deps;
|
|
573
|
+
if (!readFile || !homedir) {
|
|
574
|
+
return {
|
|
575
|
+
found: true,
|
|
576
|
+
detail: "shadowing probe unavailable (no read-only filesystem access)",
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const mainWorktreePath = await resolveMainWorktreePathForDoctor(deps);
|
|
580
|
+
const inspection = await inspectClaudeUserConfigForMcpShadowing({
|
|
581
|
+
claudeConfigPath: resolveClaudeUserConfigPath(homedir(), deps.platform),
|
|
582
|
+
platform: deps.platform,
|
|
583
|
+
cwd: deps.cwd,
|
|
584
|
+
mainRepositoryPath: mainWorktreePath ?? undefined,
|
|
585
|
+
worktreePath: deps.cwd,
|
|
586
|
+
}, { readFile });
|
|
587
|
+
const degraded = inspection.diagnostics.map(formatClaudeUserConfigDiagnostic);
|
|
588
|
+
if (mainWorktreePath === null) {
|
|
589
|
+
degraded.push("the main worktree path could not be resolved; only user and current-worktree scopes were checked");
|
|
590
|
+
}
|
|
591
|
+
if (inspection.findings.length > 0) {
|
|
592
|
+
const findings = inspection.findings.map(formatClaudeMcpShadowFinding);
|
|
593
|
+
return { found: false, detail: [...findings, ...degraded].join("; ") };
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
found: true,
|
|
597
|
+
detail: degraded.length > 0
|
|
598
|
+
? degraded.join("; ")
|
|
599
|
+
: "no foreign bridge-api registration in the Claude user config",
|
|
600
|
+
};
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
483
604
|
/**
|
|
484
605
|
* Conservatively normalize a filesystem path for comparison: trim, drop a
|
|
485
606
|
* trailing separator, and resolve `.`/`..` segments via `path.resolve`. Symlink
|
|
@@ -628,6 +749,7 @@ export function getDoctorOnlyPrereqDescriptors(_platform, _env, agent) {
|
|
|
628
749
|
ripgrepDescriptor(),
|
|
629
750
|
reviewTicketsGitDescriptor(),
|
|
630
751
|
liveSourceGuardDescriptor(),
|
|
752
|
+
claudeMcpShadowingDescriptor(),
|
|
631
753
|
];
|
|
632
754
|
}
|
|
633
755
|
/**
|
|
@@ -648,10 +770,12 @@ export function getDoctorPrereqDescriptors(platform, env, agent) {
|
|
|
648
770
|
/** Probe one descriptor without throwing, resolving its platform-specific hint. */
|
|
649
771
|
export async function probePrerequisite(deps, descriptor) {
|
|
650
772
|
let outcome;
|
|
773
|
+
let probeThrew = false;
|
|
651
774
|
try {
|
|
652
775
|
outcome = await descriptor.probe(deps);
|
|
653
776
|
}
|
|
654
777
|
catch (err) {
|
|
778
|
+
probeThrew = true;
|
|
655
779
|
outcome = { found: false, detail: err instanceof Error ? err.message : String(err) };
|
|
656
780
|
}
|
|
657
781
|
return {
|
|
@@ -661,6 +785,11 @@ export async function probePrerequisite(deps, descriptor) {
|
|
|
661
785
|
detail: outcome.detail,
|
|
662
786
|
installHint: hintForPlatform(descriptor.installHint, deps.platform),
|
|
663
787
|
authNote: descriptor.authNote,
|
|
788
|
+
// Deliberately NOT carried through when the probe THREW: the catch above
|
|
789
|
+
// replaces the audited detail with the exception's message, which is exactly
|
|
790
|
+
// the arbitrary text the missing-detail suppression exists to keep out of the
|
|
791
|
+
// report. The audit only covers details the probe returned deliberately.
|
|
792
|
+
renderDetailWhenMissing: descriptor.renderDetailWhenMissing === true && !probeThrew ? true : undefined,
|
|
664
793
|
};
|
|
665
794
|
}
|
|
666
795
|
/**
|
package/build/start-tickets.js
CHANGED
|
@@ -64,6 +64,7 @@ import { provisionMcpRegistrationsForCreatedWorktrees, } from "./mcp-provisionin
|
|
|
64
64
|
import { provisionCommandsForCreatedWorktrees, } from "./command-provisioning.js";
|
|
65
65
|
import { resolveMcpShimInvocationForRuntime, buildMcpShimCommand, } from "./mcp-server-invocation.js";
|
|
66
66
|
import { existsSync } from "node:fs";
|
|
67
|
+
import { TICKET_KEY_PATTERN as SHARED_TICKET_KEY_PATTERN, TICKET_KEY_USAGE_SUMMARY, validateTicketKey, } from "./ticket-key-utils.js";
|
|
67
68
|
// Per-OS prerequisite knowledge + low-level command probes live in the shared
|
|
68
69
|
// prereqs module so `runPreflight` (enforce) and the read-only `doctor` (render)
|
|
69
70
|
// can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
|
|
@@ -80,8 +81,15 @@ export { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERS
|
|
|
80
81
|
// ---------------------------------------------------------------------------
|
|
81
82
|
// Constants
|
|
82
83
|
// ---------------------------------------------------------------------------
|
|
83
|
-
/**
|
|
84
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The canonical ticket-key shape, e.g. `BAPI-248` or `MY_PROJ-248`.
|
|
86
|
+
*
|
|
87
|
+
* Re-exported from `ticket-key-utils.ts` (BAPI-710) rather than redefined:
|
|
88
|
+
* `review-tickets.ts` still imports the name from here, and the previous local
|
|
89
|
+
* `/^[A-Z]+-[0-9]+$/` rejected underscore-containing and single-character
|
|
90
|
+
* prefixes that the server happily mints.
|
|
91
|
+
*/
|
|
92
|
+
export const TICKET_KEY_PATTERN = SHARED_TICKET_KEY_PATTERN;
|
|
85
93
|
/** Default cap on concurrently-created worktrees. */
|
|
86
94
|
export const DEFAULT_MAX_PARALLEL = 3;
|
|
87
95
|
/** Default tmux session-name prefix; one detached session is created per ticket. */
|
|
@@ -146,7 +154,7 @@ export function getStartTicketsUsage() {
|
|
|
146
154
|
" Windows git-wt, Git for Windows / Git Bash, Windows Terminal or PowerShell",
|
|
147
155
|
" Linux wt, git, tmux",
|
|
148
156
|
"",
|
|
149
|
-
|
|
157
|
+
TICKET_KEY_USAGE_SUMMARY,
|
|
150
158
|
].join("\n");
|
|
151
159
|
}
|
|
152
160
|
/**
|
|
@@ -366,11 +374,9 @@ export function parseStartTicketsArgs(argv) {
|
|
|
366
374
|
}
|
|
367
375
|
const seen = new Set();
|
|
368
376
|
for (const key of keys) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
message: `Invalid ticket key: '${key}' (keys must match [A-Z]+-[0-9]+, e.g., BAPI-248).`,
|
|
373
|
-
};
|
|
377
|
+
const keyCheck = validateTicketKey(key);
|
|
378
|
+
if (!keyCheck.ok) {
|
|
379
|
+
return { status: "error", message: keyCheck.message };
|
|
374
380
|
}
|
|
375
381
|
if (seen.has(key)) {
|
|
376
382
|
return { status: "error", message: `Duplicate ticket key: '${key}'.` };
|
|
@@ -400,11 +406,9 @@ export function parseStartTicketsArgs(argv) {
|
|
|
400
406
|
}
|
|
401
407
|
const overrideKey = entry.slice(0, sepIndex);
|
|
402
408
|
const branchName = entry.slice(sepIndex + 1);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
message: `Invalid --branch override key: '${overrideKey}' (keys must match [A-Z]+-[0-9]+).`,
|
|
407
|
-
};
|
|
409
|
+
const overrideCheck = validateTicketKey(overrideKey, "--branch override key");
|
|
410
|
+
if (!overrideCheck.ok) {
|
|
411
|
+
return { status: "error", message: overrideCheck.message };
|
|
408
412
|
}
|
|
409
413
|
if (!seen.has(overrideKey)) {
|
|
410
414
|
return {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ticket-backend-metadata — attach the server's `X-Ticket-Backend` header to a
|
|
3
|
+
* SUCCESSFUL ticket response as labeled metadata (BAPI-710).
|
|
4
|
+
*
|
|
5
|
+
* The server has always set this header; nothing on the MCP side read it, so an
|
|
6
|
+
* agent could not tell whether a ticket came from Jira or the local backend —
|
|
7
|
+
* which matters because a `local://` URL, an absent comment thread, and a
|
|
8
|
+
* four-status workflow are all correct for one backend and a symptom for the other.
|
|
9
|
+
*
|
|
10
|
+
* Two rules hold this in its lane:
|
|
11
|
+
*
|
|
12
|
+
* - **Metadata only, success only.** The header is never an authorization or
|
|
13
|
+
* error-control signal. Error responses are governed exclusively by the
|
|
14
|
+
* structured 409 body (`tool-error-envelope.ts`), because a raised FastAPI
|
|
15
|
+
* error builds a fresh response and DROPS this header — so treating its
|
|
16
|
+
* absence as meaningful would misread every error.
|
|
17
|
+
* - **The payload survives intact.** An object gains one labeled field; an
|
|
18
|
+
* array or scalar is wrapped in an explicit `{ ticket_backend, data }`
|
|
19
|
+
* envelope rather than being flattened, coerced, or dropped.
|
|
20
|
+
*/
|
|
21
|
+
/** The response header the Bridge API sets on ticket routes. */
|
|
22
|
+
export const TICKET_BACKEND_HEADER = "X-Ticket-Backend";
|
|
23
|
+
/** The field name added to a successful response payload. */
|
|
24
|
+
export const TICKET_BACKEND_FIELD = "ticket_backend";
|
|
25
|
+
/** Narrow a raw header value to a recognized backend, or `null`. */
|
|
26
|
+
export function parseTicketBackendHeader(value) {
|
|
27
|
+
if (typeof value !== "string")
|
|
28
|
+
return null;
|
|
29
|
+
const normalized = value.trim().toLowerCase();
|
|
30
|
+
if (normalized === "local" || normalized === "jira")
|
|
31
|
+
return normalized;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Return the response body to serialize, with backend metadata attached.
|
|
36
|
+
*
|
|
37
|
+
* - `backend === null` (header absent or unrecognized) → `body` unchanged. A
|
|
38
|
+
* supplemental signal must never alter a payload just by being missing.
|
|
39
|
+
* - plain object → the original fields plus `ticket_backend`.
|
|
40
|
+
* - array / scalar / null → `{ ticket_backend, data: <original> }`, because
|
|
41
|
+
* there is nowhere on an array to put a labeled field without changing what
|
|
42
|
+
* the array means.
|
|
43
|
+
*/
|
|
44
|
+
export function attachTicketBackendMetadata(body, backend) {
|
|
45
|
+
if (backend === null)
|
|
46
|
+
return body;
|
|
47
|
+
if (body !== null && typeof body === "object" && !Array.isArray(body)) {
|
|
48
|
+
return { ...body, [TICKET_BACKEND_FIELD]: backend };
|
|
49
|
+
}
|
|
50
|
+
return { [TICKET_BACKEND_FIELD]: backend, data: body };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Serialize a successful JSON response with backend metadata attached.
|
|
54
|
+
* Formatting matches `handleResponse`'s existing `JSON.stringify(body, null, 2)`.
|
|
55
|
+
*/
|
|
56
|
+
export function formatSuccessWithTicketBackend(body, headerValue) {
|
|
57
|
+
const backend = parseTicketBackendHeader(headerValue);
|
|
58
|
+
return JSON.stringify(attachTicketBackendMetadata(body, backend), null, 2);
|
|
59
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ticket-key-utils — the ONE ticket-key grammar every MCP and CLI surface uses.
|
|
3
|
+
*
|
|
4
|
+
* Before BAPI-710 three patterns disagreed across the client/server boundary:
|
|
5
|
+
*
|
|
6
|
+
* | Layer | Pattern | Accepts `MY_PROJ` |
|
|
7
|
+
* | ------------------------------------------- | ------------------------ | ----------------- |
|
|
8
|
+
* | server `field_validators.py` (prefix) | `^[A-Z][A-Z0-9_]{0,19}$` | yes |
|
|
9
|
+
* | `chain-orchestrator.ts` | `^[A-Z][A-Z0-9]+-\d+$` | no |
|
|
10
|
+
* | `start-tickets.ts` | `^[A-Z]+-[0-9]+$` | no |
|
|
11
|
+
*
|
|
12
|
+
* A repository configured with `jira_ticket_key = "MY_PROJ"` therefore minted
|
|
13
|
+
* keys the server considered valid and the clients rejected — `/full-automation`
|
|
14
|
+
* silently dropped them. Jira project keys may contain underscores too, so this
|
|
15
|
+
* was never a local-mode-only defect.
|
|
16
|
+
*
|
|
17
|
+
* This module mirrors the public Python contract
|
|
18
|
+
* (`api.library.config.field_validators.TICKET_KEY_PREFIX_PATTERN_SOURCE` plus a
|
|
19
|
+
* `-<digits>` suffix). The two must be changed together; the Python side is
|
|
20
|
+
* authoritative because it is what mints keys.
|
|
21
|
+
*
|
|
22
|
+
* Everything here is pure: no I/O, no normalization. A key that reaches a client
|
|
23
|
+
* has already been minted, so it must round-trip byte-for-byte — upper-casing or
|
|
24
|
+
* trimming it here would silently retarget or invent a ticket.
|
|
25
|
+
*/
|
|
26
|
+
/** The configured prefix grammar, mirroring the server's public prefix source. */
|
|
27
|
+
export const TICKET_KEY_PREFIX_PATTERN_SOURCE = "[A-Z][A-Z0-9_]{0,19}";
|
|
28
|
+
/**
|
|
29
|
+
* A complete ticket key: an upper-case prefix (a letter followed by up to 19
|
|
30
|
+
* letters, digits, or underscores), a hyphen, and a numeric suffix.
|
|
31
|
+
* Examples: `A-1`, `BAPI-248`, `MY_PROJ-248`, `A1_B2-0007`.
|
|
32
|
+
*/
|
|
33
|
+
export const TICKET_KEY_PATTERN = /^[A-Z][A-Z0-9_]{0,19}-\d+$/;
|
|
34
|
+
/**
|
|
35
|
+
* The single actionable rejection message. Names every rule the grammar
|
|
36
|
+
* enforces, so one failure is enough for a caller to correct its input.
|
|
37
|
+
*/
|
|
38
|
+
export const TICKET_KEY_VALIDATION_MESSAGE = "keys must be an upper-case prefix (a letter followed by up to 19 letters, " +
|
|
39
|
+
"digits, or underscores — 20 characters maximum), a hyphen, and a number " +
|
|
40
|
+
"(e.g., BAPI-248, MY_PROJ-248, A-1)";
|
|
41
|
+
/** Compact contract summary for usage/help text. */
|
|
42
|
+
export const TICKET_KEY_USAGE_SUMMARY = "Each KEY is an upper-case prefix (letters, digits, underscores; 20 max), a " +
|
|
43
|
+
"hyphen, and a number (e.g., BAPI-248, MY_PROJ-248).";
|
|
44
|
+
/** True when `value` is exactly a canonical ticket key. Never normalizes. */
|
|
45
|
+
export function isValidTicketKey(value) {
|
|
46
|
+
return typeof value === "string" && TICKET_KEY_PATTERN.test(value);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validate a single key, returning an actionable message on failure.
|
|
50
|
+
*
|
|
51
|
+
* The rejected value is echoed via `JSON.stringify` so an empty string, a value
|
|
52
|
+
* with trailing whitespace, or a non-string is visibly distinguishable in the
|
|
53
|
+
* error — "Invalid ticket key: ''" is otherwise indistinguishable from a bug in
|
|
54
|
+
* the caller's own formatting.
|
|
55
|
+
*/
|
|
56
|
+
export function validateTicketKey(value, label = "ticket key") {
|
|
57
|
+
if (isValidTicketKey(value))
|
|
58
|
+
return { ok: true };
|
|
59
|
+
const rendered = typeof value === "string" ? value : JSON.stringify(value);
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
invalidKeys: [String(rendered)],
|
|
63
|
+
message: `Invalid ${label}: ${JSON.stringify(rendered)} — ${TICKET_KEY_VALIDATION_MESSAGE}.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Validate a whole list and report EVERY rejected key in one message.
|
|
68
|
+
*
|
|
69
|
+
* Reporting the complete rejected set (rather than the first failure, or —
|
|
70
|
+
* worse — silently continuing with the valid subset) is the point: a caller that
|
|
71
|
+
* learns about one bad key at a time needs one round trip per key, and a caller
|
|
72
|
+
* that is never told at all runs downstream side effects against a quietly
|
|
73
|
+
* truncated list.
|
|
74
|
+
*/
|
|
75
|
+
export function validateTicketKeys(values, label = "ticket key") {
|
|
76
|
+
const invalidKeys = [];
|
|
77
|
+
for (const value of values) {
|
|
78
|
+
if (isValidTicketKey(value))
|
|
79
|
+
continue;
|
|
80
|
+
invalidKeys.push(typeof value === "string" ? value : JSON.stringify(value));
|
|
81
|
+
}
|
|
82
|
+
if (invalidKeys.length === 0)
|
|
83
|
+
return { ok: true };
|
|
84
|
+
const plural = invalidKeys.length === 1 ? "" : "s";
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
invalidKeys,
|
|
88
|
+
message: `Invalid ${label}${plural}: ` +
|
|
89
|
+
`${invalidKeys.map((k) => JSON.stringify(k)).join(", ")} — ` +
|
|
90
|
+
`${TICKET_KEY_VALIDATION_MESSAGE}.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-error-envelope — pure parsing for the serialized MCP error envelopes that
|
|
3
|
+
* `handleResponse` (index.ts) emits for a non-OK Bridge API response.
|
|
4
|
+
*
|
|
5
|
+
* The envelope shape is `{ error: string, status: number, message: string, ... }`.
|
|
6
|
+
* One condition is recognized specifically (BAPI-710): the backend's terminal
|
|
7
|
+
* "this operation has no local-backend equivalent" refusal, which the ticket
|
|
8
|
+
* routes return as HTTP 409 with `detail.error = "UNSUPPORTED_IN_LOCAL_MODE"`.
|
|
9
|
+
*
|
|
10
|
+
* Recognition is by the exact `(error, status)` PAIR — never by message text.
|
|
11
|
+
* Message text is prose that gets reworded; matching on it would make a copy
|
|
12
|
+
* edit silently change orchestration control flow.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure: parse, inspect, return. Malformed JSON, scalars,
|
|
15
|
+
* arrays, and absent fields all produce a safe non-match rather than throwing.
|
|
16
|
+
*/
|
|
17
|
+
/** The recognized machine-readable marker for an unsupported local operation. */
|
|
18
|
+
export const UNSUPPORTED_IN_LOCAL_MODE_ERROR = "UNSUPPORTED_IN_LOCAL_MODE";
|
|
19
|
+
/** The HTTP status that marker is only ever valid with. */
|
|
20
|
+
export const UNSUPPORTED_IN_LOCAL_MODE_STATUS = 409;
|
|
21
|
+
/**
|
|
22
|
+
* Parse `text` as a tool-error envelope, or return `null`.
|
|
23
|
+
*
|
|
24
|
+
* Requires a JSON object with a string `error` and a numeric `status` — the same
|
|
25
|
+
* strict shape `pipeline-orchestrator.ts`'s `isToolFailureEnvelope` uses, so the
|
|
26
|
+
* two agree on what counts as a failure envelope.
|
|
27
|
+
*/
|
|
28
|
+
export function parseToolErrorEnvelope(text) {
|
|
29
|
+
if (typeof text !== "string")
|
|
30
|
+
return null;
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const obj = parsed;
|
|
42
|
+
if (typeof obj.error !== "string" || typeof obj.status !== "number")
|
|
43
|
+
return null;
|
|
44
|
+
const envelope = {
|
|
45
|
+
error: obj.error,
|
|
46
|
+
status: obj.status,
|
|
47
|
+
message: typeof obj.message === "string" ? obj.message : "",
|
|
48
|
+
};
|
|
49
|
+
if (Array.isArray(obj.supported_in)) {
|
|
50
|
+
envelope.supported_in = obj.supported_in.filter((v) => typeof v === "string");
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(obj.unsupported_fields)) {
|
|
53
|
+
envelope.unsupported_fields = obj.unsupported_fields.filter((v) => typeof v === "string");
|
|
54
|
+
}
|
|
55
|
+
return envelope;
|
|
56
|
+
}
|
|
57
|
+
/** True for the exact unsupported-local contract: the marker AND HTTP 409. */
|
|
58
|
+
export function isUnsupportedInLocalModeEnvelope(envelope) {
|
|
59
|
+
return (envelope !== null &&
|
|
60
|
+
envelope.error === UNSUPPORTED_IN_LOCAL_MODE_ERROR &&
|
|
61
|
+
envelope.status === UNSUPPORTED_IN_LOCAL_MODE_STATUS);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Convenience: parse `text` and return the envelope only when it is the
|
|
65
|
+
* recognized unsupported-local refusal. Returns `null` for everything else,
|
|
66
|
+
* including an ordinary 409 and a 500 that happens to carry the marker string.
|
|
67
|
+
*/
|
|
68
|
+
export function parseUnsupportedInLocalModeEnvelope(text) {
|
|
69
|
+
const envelope = parseToolErrorEnvelope(text);
|
|
70
|
+
return isUnsupportedInLocalModeEnvelope(envelope) ? envelope : null;
|
|
71
|
+
}
|
|
@@ -371,6 +371,78 @@ function setsEqual(a, b) {
|
|
|
371
371
|
}
|
|
372
372
|
return true;
|
|
373
373
|
}
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Update-staleness advisory decoration (BAPI-728)
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
/** The `_meta` key carrying the structured staleness advisory. */
|
|
378
|
+
export const UPDATE_ADVISORY_META_KEY = "bridge-api/update-advisory";
|
|
379
|
+
/** The tool whose description carries the model-visible advisory when stale. */
|
|
380
|
+
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. The static tool catalog is
|
|
403
|
+
* untouched, so the tools-budget ratchet and the description prose pins that
|
|
404
|
+
* guard `mcp_server/src/index.ts` are unaffected.
|
|
405
|
+
*
|
|
406
|
+
* Fail-open: if the advisory cannot be produced, the original result is returned
|
|
407
|
+
* byte-for-byte.
|
|
408
|
+
*/
|
|
409
|
+
export function decorateListResultWithUpdateAdvisory(original, advisory) {
|
|
410
|
+
if (!advisory)
|
|
411
|
+
return original;
|
|
412
|
+
try {
|
|
413
|
+
const tools = original.tools.map((tool) => tool.name === ADVISORY_HOST_TOOL
|
|
414
|
+
? { ...tool, description: `${advisory}\n\n${tool.description ?? ""}`.trimEnd() }
|
|
415
|
+
: tool);
|
|
416
|
+
return {
|
|
417
|
+
...original,
|
|
418
|
+
tools,
|
|
419
|
+
_meta: { ...(original._meta ?? {}), [UPDATE_ADVISORY_META_KEY]: advisory },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return original;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Compose an advisory-decorating handler over `inner`.
|
|
428
|
+
*
|
|
429
|
+
* `inner` is the capability gate's handler when dynamic gating is enabled, and
|
|
430
|
+
* the SDK's own captured handler when it is disabled — so turning gating off
|
|
431
|
+
* never turns update visibility off with it.
|
|
432
|
+
*/
|
|
433
|
+
export function createUpdateAdvisoryListHandler(inner, getAdvisory, onListServed) {
|
|
434
|
+
return async (request, extra) => {
|
|
435
|
+
const result = await inner(request, extra);
|
|
436
|
+
try {
|
|
437
|
+
onListServed?.();
|
|
438
|
+
return decorateListResultWithUpdateAdvisory(result, getAdvisory());
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// Never let advisory decoration break tool discovery.
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
374
446
|
/** Fixed, sanitized boot error (never includes map contents or config). */
|
|
375
447
|
const COMPAT_ERROR = "tool-surface gating: incompatible MCP SDK — the tools/list handler could not be resolved for override.";
|
|
376
448
|
/**
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide cached update status (BAPI-728).
|
|
3
|
+
*
|
|
4
|
+
* The staleness signal already existed — `checkForUpdate()` ran on every server
|
|
5
|
+
* start and computed the right answer. It was emitted through
|
|
6
|
+
* `sendLoggingMessage({ level: "notice", ... })`, an MCP log sink that no user
|
|
7
|
+
* or model surface reads. The published 0.2.16 tarball shipped that code, so a
|
|
8
|
+
* stale server correctly computed `updateAvailable: true` and announced it to
|
|
9
|
+
* nobody, on every session, for twenty releases.
|
|
10
|
+
*
|
|
11
|
+
* This manager gives that value two readers instead of zero: one stderr warning
|
|
12
|
+
* per process (humans), and a `tools/list` advisory (models). It is:
|
|
13
|
+
*
|
|
14
|
+
* - **single-flight** — one `checkForUpdate()` per process, cached forever;
|
|
15
|
+
* - **non-blocking** — started at boot, never awaited before `connect`;
|
|
16
|
+
* - **fail-open** — a null, malformed, rejected, or not-newer result yields
|
|
17
|
+
* no advisory and no warning, and never throws.
|
|
18
|
+
*/
|
|
19
|
+
import { checkForUpdate } from "./update-check.js";
|
|
20
|
+
import { formatUpdateAdvice, formatToolSurfaceUpdateAdvisory } from "./upgrade-advice.js";
|
|
21
|
+
/** The not-stale conclusion, used for every fail-open branch. */
|
|
22
|
+
const NOT_STALE = { stale: false };
|
|
23
|
+
/**
|
|
24
|
+
* Build the manager. Nothing runs until `start()` is called, so importing this
|
|
25
|
+
* module never performs I/O.
|
|
26
|
+
*/
|
|
27
|
+
export function createUpdateStatusManager(options = {}) {
|
|
28
|
+
const check = options.check ?? checkForUpdate;
|
|
29
|
+
const warn = options.warn ?? ((message) => console.error(message));
|
|
30
|
+
let started = false;
|
|
31
|
+
let settled = null;
|
|
32
|
+
let settledPromise = null;
|
|
33
|
+
let warned = false;
|
|
34
|
+
let listServed = false;
|
|
35
|
+
let lateNotified = false;
|
|
36
|
+
function conclude(result) {
|
|
37
|
+
// Every uncertain shape is fail-open. A malformed payload must never produce
|
|
38
|
+
// an advisory telling a user to upgrade to a version that may not exist.
|
|
39
|
+
if (!result || typeof result !== "object")
|
|
40
|
+
return NOT_STALE;
|
|
41
|
+
if (result.updateAvailable !== true)
|
|
42
|
+
return NOT_STALE;
|
|
43
|
+
const { currentVersion, latestVersion } = result;
|
|
44
|
+
if (typeof currentVersion !== "string" || currentVersion.length === 0)
|
|
45
|
+
return NOT_STALE;
|
|
46
|
+
if (typeof latestVersion !== "string" || latestVersion.length === 0)
|
|
47
|
+
return NOT_STALE;
|
|
48
|
+
return { stale: true, currentVersion, latestVersion };
|
|
49
|
+
}
|
|
50
|
+
function start() {
|
|
51
|
+
if (started)
|
|
52
|
+
return;
|
|
53
|
+
started = true;
|
|
54
|
+
settledPromise = (async () => {
|
|
55
|
+
let status;
|
|
56
|
+
try {
|
|
57
|
+
status = conclude(await check());
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
status = NOT_STALE; // `checkForUpdate` is contracted not to reject; belt and braces.
|
|
61
|
+
}
|
|
62
|
+
settled = status;
|
|
63
|
+
if (status.stale && !warned) {
|
|
64
|
+
warned = true;
|
|
65
|
+
// stderr, never stdout: stdout is the JSON-RPC frame channel and any
|
|
66
|
+
// stray write there corrupts the protocol stream.
|
|
67
|
+
warn(formatUpdateAdvice(status.currentVersion, status.latestVersion));
|
|
68
|
+
if (listServed && !lateNotified) {
|
|
69
|
+
lateNotified = true;
|
|
70
|
+
try {
|
|
71
|
+
options.onLateStale?.();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A notification failure must never affect server operation.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return status;
|
|
79
|
+
})();
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
start,
|
|
83
|
+
getStatus: () => settled ?? NOT_STALE,
|
|
84
|
+
whenSettled: async () => {
|
|
85
|
+
if (!started)
|
|
86
|
+
start();
|
|
87
|
+
return (await settledPromise) ?? NOT_STALE;
|
|
88
|
+
},
|
|
89
|
+
markListServed: () => {
|
|
90
|
+
listServed = true;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Produce the model-visible advisory for a settled status, or null when the
|
|
96
|
+
* status is not (yet) known-stale. Pure — safe to call on every list request.
|
|
97
|
+
*/
|
|
98
|
+
export function updateAdvisoryFor(status) {
|
|
99
|
+
if (!status.stale || !status.currentVersion || !status.latestVersion)
|
|
100
|
+
return null;
|
|
101
|
+
return formatToolSurfaceUpdateAdvisory(status.currentVersion, status.latestVersion);
|
|
102
|
+
}
|