@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.
Files changed (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. package/build/conductor/supervisor-judgment.js +0 -215
@@ -11,20 +11,36 @@
11
11
  * (BM username + 40-char WebDAV access key) that lives entirely server-side; the
12
12
  * spike proved an AM bearer token 401s on `/Logs`. Readiness is resolved by the
13
13
  * backend `GET /jira/sfcc/logs/capability` probe, which never returns a secret.
14
+ *
15
+ * BAPI-766 changed only the *representation* of this gate's failures: they now
16
+ * use the universal SFCC envelope from `sfcc-result.ts`, so one detection rule
17
+ * (`result.isError === true`) and one parse path cover the log surface too. The
18
+ * gate itself remains operationally separate — the WebDAV Basic-auth boundary is
19
+ * still distinct from the OCAPI AM-OAuth boundary, and the two gates are not
20
+ * merged. A capability-probe failure is reported with source `"webdav"`, never
21
+ * `"ocapi"`, because that is where it actually originates.
14
22
  */
23
+ import { formatSfccFailure } from "./sfcc-result.js";
15
24
  // ---------------------------------------------------------------------------
16
25
  // Envelope helpers
17
26
  // ---------------------------------------------------------------------------
18
- function jsonResult(payload) {
19
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
20
- }
27
+ /**
28
+ * Build the unified log-gate not-configured failure.
29
+ *
30
+ * The 503 is synthetic — Bridge's own status, not something WebDAV returned — so
31
+ * no upstream HTTP status is supplied. `source` is `"gate"`, matching the OCAPI
32
+ * gate: the rejection is the gate's decision, not an upstream response.
33
+ */
21
34
  function notConfigured(failureClass, message, limits) {
22
- return jsonResult({
23
- error: "NOT_CONFIGURED",
35
+ return formatSfccFailure({
36
+ code: "NOT_CONFIGURED",
24
37
  status: 503,
25
- failure_class: failureClass,
26
38
  message,
27
- ...(limits !== undefined ? { limits } : {}),
39
+ source: "gate",
40
+ details: {
41
+ failure_class: failureClass,
42
+ ...(limits !== undefined ? { limits } : {}),
43
+ },
28
44
  });
29
45
  }
30
46
  // ---------------------------------------------------------------------------
@@ -54,22 +70,33 @@ export function withSfccLogGate(deps, handler) {
54
70
  const resp = await fetch(url, { headers: await deps.getGetHeaders() });
55
71
  if (!resp.ok) {
56
72
  // Distinguish a Bridge auth/connectivity failure from "not configured".
57
- return jsonResult({
58
- error: resp.status === 401 || resp.status === 403 ? "UNAUTHORIZED" : "SERVICE_UNAVAILABLE",
73
+ // The reported status IS the upstream status, so the shared formatter
74
+ // rightly omits a duplicate `upstream_http_status`.
75
+ return formatSfccFailure({
76
+ code: resp.status === 401 || resp.status === 403
77
+ ? "UNAUTHORIZED"
78
+ : "SERVICE_UNAVAILABLE",
59
79
  status: resp.status,
60
80
  message: "Could not read the SFCC log capability from Bridge API " +
61
81
  "(/jira/sfcc/logs/capability). Ensure your Bridge API key is set and the " +
62
82
  "repo is authorized. Run sfcc_setup_status for a full diagnostic.",
83
+ source: "webdav",
84
+ details: {},
85
+ upstreamHttpStatus: resp.status,
63
86
  });
64
87
  }
65
88
  body = (await resp.json());
66
89
  }
67
90
  catch {
68
- return jsonResult({
69
- error: "BAD_GATEWAY",
91
+ // A synthetic 502: no upstream responded at all, so no upstream status
92
+ // may be reported.
93
+ return formatSfccFailure({
94
+ code: "BAD_GATEWAY",
70
95
  status: 502,
71
96
  message: "Could not reach Bridge API to resolve the SFCC log capability. " +
72
97
  "Check that BAPI_BASE_URL points to a running Bridge API instance.",
98
+ source: "webdav",
99
+ details: {},
73
100
  });
74
101
  }
75
102
  if (body?.configured !== true) {
@@ -11,9 +11,17 @@
11
11
  * The backend response has already passed through the `RedactionPort`, so its JSON
12
12
  * is returned to the LLM unchanged; there is deliberately NO local retrieval
13
13
  * bypass in TypeScript.
14
+ *
15
+ * BAPI-766 moved this tool's failure representation onto the universal SFCC
16
+ * envelope (`sfcc-result.ts`), so `result.isError === true` and one parse path
17
+ * cover the log surface exactly as they cover reads and writes. Redaction is
18
+ * untouched: a non-OK backend response contributes only its Bridge-authored
19
+ * string `detail` to `error.details`, and never raw text or an unknown payload
20
+ * whose redaction cannot be established (see `safeBackendDetail`).
14
21
  */
15
22
  import { z } from "zod";
16
23
  import { withSfccLogGate } from "./log-gate.js";
24
+ import { formatSfccFailure } from "./sfcc-result.js";
17
25
  // ---------------------------------------------------------------------------
18
26
  // Bounds — mirror src/python/sfcc_monitor/log_query.py (kept in lock-step).
19
27
  // ---------------------------------------------------------------------------
@@ -24,11 +32,42 @@ const MAX_SELECTED_PREFIXES = 5;
24
32
  const MAX_MAX_ENTRIES = 2000;
25
33
  const SUPPORTED_ENVIRONMENTS = ["production", "staging", "development"];
26
34
  const PREFIX_RE = /^[a-z][a-z0-9]*$/;
27
- function jsonResult(payload) {
28
- return { content: [{ type: "text", text: JSON.stringify(payload) }] };
35
+ /** Bridge-authored message for a non-OK backend query response. */
36
+ function backendFailureMessage(status) {
37
+ return (`The Bridge API SFCC log-query endpoint returned HTTP ${status}. ` +
38
+ `No log content is included in this response.`);
39
+ }
40
+ /**
41
+ * Project a non-OK backend response body down to what is provably safe.
42
+ *
43
+ * Every failure the backend raises (`api/routes/sfcc_log_query.py`) is an
44
+ * `HTTPException` whose `detail` is a fixed Bridge-authored string — never log
45
+ * content. Anything else (a request-validation 422's structured list, an
46
+ * unparsed body, an unexpected payload) has no redaction guarantee, so it is
47
+ * dropped rather than forwarded. Redaction is a shipped safety property
48
+ * (BAPI-554); reformatting must not become a way around it.
49
+ */
50
+ function safeBackendDetail(text) {
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(text);
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
59
+ return {};
60
+ const detail = parsed.detail;
61
+ return typeof detail === "string" ? { detail } : {};
29
62
  }
30
63
  function validationError(message) {
31
- return jsonResult({ error: "VALIDATION_ERROR", status: 400, message });
64
+ return formatSfccFailure({
65
+ code: "VALIDATION_ERROR",
66
+ status: 400,
67
+ message,
68
+ source: "tool",
69
+ details: {},
70
+ });
32
71
  }
33
72
  // ---------------------------------------------------------------------------
34
73
  // Zod input schema
@@ -118,26 +157,27 @@ function buildHandler(deps) {
118
157
  });
119
158
  }
120
159
  catch {
121
- return jsonResult({
122
- error: "BAD_GATEWAY",
160
+ // Synthetic 502: nothing responded, so no upstream status may be reported.
161
+ return formatSfccFailure({
162
+ code: "BAD_GATEWAY",
123
163
  status: 502,
124
164
  message: "Could not reach Bridge API to run the SFCC log query. Check that " +
125
165
  "BAPI_BASE_URL points to a running Bridge API instance.",
166
+ source: "webdav",
167
+ details: {},
126
168
  });
127
169
  }
128
170
  const text = await resp.text();
129
171
  if (!resp.ok) {
130
- let detail = text;
131
- try {
132
- detail = JSON.parse(text);
133
- }
134
- catch {
135
- /* keep raw text detail */
136
- }
137
- return jsonResult({
138
- error: resp.status >= 500 ? "SERVICE_UNAVAILABLE" : "REQUEST_FAILED",
172
+ // The reported status IS the upstream status, so the shared formatter
173
+ // rightly omits a duplicate `upstream_http_status`.
174
+ return formatSfccFailure({
175
+ code: resp.status >= 500 ? "SERVICE_UNAVAILABLE" : "REQUEST_FAILED",
139
176
  status: resp.status,
140
- detail,
177
+ message: backendFailureMessage(resp.status),
178
+ source: "webdav",
179
+ details: safeBackendDetail(text),
180
+ upstreamHttpStatus: resp.status,
141
181
  });
142
182
  }
143
183
  // The backend response has already passed through RedactionPort — return it
@@ -5,13 +5,49 @@
5
5
  * envelope. This module provides a typed extractor for the `data` array so
6
6
  * sub-task read tools share one normalizer rather than each re-deriving the
7
7
  * shape.
8
+ *
9
+ * Collection metadata lives under a single `page` object rather than at the top
10
+ * level (BAPI-760). Two properties of that metadata are load-bearing:
11
+ *
12
+ * - `returned` is the actual length of the `items` array this call produced.
13
+ * It is never read from the upstream `count` field, because OCAPI's `count`
14
+ * is a request-echo (the page size asked for) and not a guarantee about how
15
+ * many records came back.
16
+ * - `total` exists ONLY when OCAPI itself supplied a numeric `total`. It is
17
+ * never inferred from `items.length`, and it is absent — not `null`, not
18
+ * `0` — when OCAPI omitted it. A consumer that sees no `total` knows the
19
+ * instance-wide count is genuinely unavailable rather than zero.
20
+ *
21
+ * Nesting the metadata under `page` also keeps it from colliding with fields
22
+ * that legitimately live inside the returned resources: an OCAPI attribute
23
+ * search document carries its own `count` and `hits`, and those stay untouched
24
+ * inside `items`.
25
+ */
26
+ /**
27
+ * Single constructor for page metadata, shared by both normalizers.
28
+ *
29
+ * Centralizing it means `returned`, `total`, and `has_more` cannot be derived
30
+ * two different ways in the strict and permissive paths. `total` is attached
31
+ * conditionally so an unsupplied total is an absent property rather than an
32
+ * explicit `undefined` that would serialize as a present-but-empty field.
8
33
  */
34
+ function buildPageMeta(items, rawTotal, rawNext) {
35
+ const page = {
36
+ returned: items.length,
37
+ has_more: rawNext != null,
38
+ };
39
+ if (typeof rawTotal === "number") {
40
+ page.total = rawTotal;
41
+ }
42
+ return page;
43
+ }
9
44
  /**
10
45
  * Parse a raw OCAPI response body into a normalized envelope result.
11
46
  *
12
47
  * Returns `null` when `body` is not a valid OCAPI paging envelope (missing
13
48
  * `data` array or `count` field), allowing callers to surface a structured
14
- * error instead of crashing.
49
+ * error instead of crashing. Strict validation is unchanged; only the returned
50
+ * metadata shape moved under `page`.
15
51
  */
16
52
  export function normalizeOcapiPage(body) {
17
53
  if (body === null ||
@@ -23,9 +59,7 @@ export function normalizeOcapiPage(body) {
23
59
  const envelope = body;
24
60
  return {
25
61
  items: envelope.data,
26
- count: envelope.count,
27
- total: envelope.total,
28
- hasMore: envelope.next != null,
62
+ page: buildPageMeta(envelope.data, envelope.total, envelope.next),
29
63
  };
30
64
  }
31
65
  /**
@@ -40,11 +74,16 @@ export function normalizeOcapiPage(body) {
40
74
  *
41
75
  * Unlike `normalizeOcapiPage`, this function never returns null and is
42
76
  * forward-compatible with future OCAPI envelope shapes.
77
+ *
78
+ * Items are passed through verbatim in every branch. A bare object — including
79
+ * an attribute-search document with its own `count` and `hits` — becomes the
80
+ * single item unchanged; its `count` is never promoted to collection metadata,
81
+ * and a missing `hits` is never synthesized.
43
82
  */
44
83
  export function normalizeOcapiBody(body) {
45
84
  // Bare array
46
85
  if (Array.isArray(body)) {
47
- return { items: body, count: body.length, total: undefined, hasMore: false };
86
+ return { items: body, page: buildPageMeta(body, undefined, null) };
48
87
  }
49
88
  // Object forms
50
89
  if (body !== null && typeof body === "object") {
@@ -52,18 +91,16 @@ export function normalizeOcapiBody(body) {
52
91
  // Wrapped envelope: has a data array (with or without explicit count)
53
92
  if (Array.isArray(obj.data)) {
54
93
  const items = obj.data;
55
- return {
56
- items,
57
- count: typeof obj.count === "number" ? obj.count : items.length,
58
- total: typeof obj.total === "number" ? obj.total : undefined,
59
- hasMore: obj.next != null,
60
- };
94
+ return { items, page: buildPageMeta(items, obj.total, obj.next) };
61
95
  }
62
- // Bare object (single resource, no wrapping envelope)
63
- return { items: [body], count: 1, total: undefined, hasMore: false };
96
+ // Bare object (single resource, no wrapping envelope). A directly supplied
97
+ // numeric `total` and paging signal are honored; the object's own `count`
98
+ // is left inside the item and never reinterpreted as a total.
99
+ const items = [body];
100
+ return { items, page: buildPageMeta(items, obj.total, obj.next) };
64
101
  }
65
102
  // Non-object (null, string, number, etc.)
66
- return { items: [], count: 0, total: undefined, hasMore: false };
103
+ return { items: [], page: buildPageMeta([], undefined, null) };
67
104
  }
68
105
  /**
69
106
  * SPEC §4.5 `value_type` mapper.
@@ -4,23 +4,30 @@
4
4
  * Re-exports the truncation + docs-path utilities through a stable sfcc/
5
5
  * interface so sub-task read tools import from here instead of reaching into
6
6
  * the host module directly. This boundary makes a future extraction cheap.
7
+ *
8
+ * Oversized responses return a JSON descriptor rather than a sliced prefix
9
+ * (BAPI-760). Slicing a serialized JSON payload at a fixed character offset
10
+ * almost always cuts mid-token, so what reached the caller was neither the data
11
+ * nor valid JSON — and appending an English note made it unparseable by
12
+ * construction. Every branch here now emits JSON the caller can parse, and the
13
+ * collection metadata travels outside the omitted data so `returned`, `total`,
14
+ * and `has_more` survive truncation.
7
15
  */
8
16
  import path from "path";
9
17
  import { mkdir, writeFile } from "fs/promises";
10
18
  /** Maximum characters returned inline; content beyond this is saved locally. */
11
19
  export const SFCC_MAX_INLINE = 50_000;
12
- /** Truncation note appended to truncated payloads. */
13
- function truncationNote(savedPath) {
14
- return `\n\n[Response truncated — full payload saved to ${savedPath}]`;
15
- }
16
20
  /**
17
21
  * If `text` exceeds `SFCC_MAX_INLINE`, save it to `dir/filename` and return a
18
- * truncated version with a local-path note. Otherwise returns `text` unchanged.
22
+ * JSON truncation descriptor naming the saved file and carrying `page`.
23
+ * Otherwise returns `text` unchanged.
19
24
  *
20
- * Write failures are non-fatal: the full (un-truncated) text is returned with
21
- * a warning rather than throwing.
25
+ * Write failures are non-fatal: a JSON save-failure descriptor carrying the
26
+ * complete payload is returned rather than throwing. This is deliberately kept
27
+ * on the successful-read path — the OCAPI read itself succeeded, so it does not
28
+ * route through `formatSfccReadFailure` and does not set `isError`.
22
29
  */
23
- export async function truncateAndSaveIfNeeded(text, dir, filename, deps = {}) {
30
+ export async function truncateAndSaveIfNeeded(text, dir, filename, page, deps = {}) {
24
31
  if (text.length <= SFCC_MAX_INLINE) {
25
32
  return text;
26
33
  }
@@ -32,8 +39,31 @@ export async function truncateAndSaveIfNeeded(text, dir, filename, deps = {}) {
32
39
  await wf(filePath, text, "utf-8");
33
40
  }
34
41
  catch (err) {
35
- return (text +
36
- `\n\nWarning: response was NOT truncated because local save failed: ${err instanceof Error ? err.message : String(err)}`);
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ const descriptor = {
44
+ truncated: false,
45
+ save_failed: true,
46
+ oversized: true,
47
+ warning: `Response was NOT truncated because the local save failed: ${message}. ` +
48
+ `The complete payload is returned inline.`,
49
+ page,
50
+ };
51
+ // Parse before embedding so callers receive JSON data rather than a
52
+ // JSON string containing escaped JSON. If the caller's text was never
53
+ // valid JSON, fall back to carrying it verbatim as a string — the helper
54
+ // itself must never emit invalid JSON.
55
+ try {
56
+ descriptor.data = JSON.parse(text);
57
+ }
58
+ catch {
59
+ descriptor.data_text = text;
60
+ }
61
+ return JSON.stringify(descriptor);
37
62
  }
38
- return text.slice(0, SFCC_MAX_INLINE) + truncationNote(filePath);
63
+ const descriptor = {
64
+ truncated: true,
65
+ saved_path: filePath,
66
+ page,
67
+ };
68
+ return JSON.stringify(descriptor);
39
69
  }
@@ -82,7 +82,6 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
82
82
  }
83
83
  if (result.ok) {
84
84
  const normalized = normalizeOcapiPage(result.body);
85
- const itemCount = normalized ? normalized.count : "unknown";
86
85
  return {
87
86
  content: [
88
87
  {
@@ -90,7 +89,7 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
90
89
  text: `✓ OCAPI access confirmed.\n` +
91
90
  ` Instance: ${credentials.hostname.split(".")[0]}\n` +
92
91
  ` OCAPI version: ${ocapiVersion}\n` +
93
- ` GET /system_object_definitions: ${itemCount} items returned`,
92
+ ` GET /system_object_definitions: ${formatProbeCount(normalized)}`,
94
93
  },
95
94
  ],
96
95
  };
@@ -116,6 +115,29 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
116
115
  ],
117
116
  };
118
117
  }
118
+ /**
119
+ * Render the probe's count line without overstating what OCAPI reported.
120
+ *
121
+ * The probe reads one page, so the number of records it got back is a page
122
+ * count, not the instance-wide total. Saying "N items returned" made a page of
123
+ * 25 look like an instance that owns exactly 25 system objects. Three distinct
124
+ * cases are now distinguishable:
125
+ *
126
+ * - OCAPI supplied a total → "25 of 73 items returned"
127
+ * - OCAPI omitted the total → the number is labelled as the current page and
128
+ * the missing instance total is stated outright
129
+ * - strict normalization failed → neither number is claimed
130
+ */
131
+ function formatProbeCount(normalized) {
132
+ if (!normalized) {
133
+ return "page count and instance total unavailable (unrecognized response envelope)";
134
+ }
135
+ const { returned, total } = normalized.page;
136
+ if (total !== undefined) {
137
+ return `${returned} of ${total} items returned`;
138
+ }
139
+ return `${returned} items returned on this page (OCAPI did not provide the instance total)`;
140
+ }
119
141
  function ocapiSettingsInstructions(ocapiVersion) {
120
142
  return (`To grant OCAPI access, paste the JSON below in Business Manager:\n` +
121
143
  ` Administration > Site Development > Open Commerce API Settings → Data API tab\n\n` +
@@ -0,0 +1,181 @@
1
+ /**
2
+ * SFCC read projection policy (BAPI-767).
3
+ *
4
+ * WHY THIS MODULE EXISTS
5
+ * ----------------------
6
+ * Before BAPI-767 no `select` was constructed anywhere in the SFCC read path.
7
+ * The only occurrence of the OCAPI full-projection expression in the tree was
8
+ * the *grant template* in `permissions.ts` (`read_attributes: "(**)"`) — a
9
+ * permission the tools were never actually exercising. The consequence was the
10
+ * headline finding of the 2026-08-11 introspection report: an agent could
11
+ * enumerate every attribute id on a type and learn nothing about any of them —
12
+ * not the `value_type`, not `mandatory`, `localizable`, `searchable`, or
13
+ * `site_specific`. The MCP could *write* a type it could not read back.
14
+ *
15
+ * The fix is a projection, and the reason it lives in one module rather than in
16
+ * each handler is that the *default* is the load-bearing part. "Complete by
17
+ * default on attribute reads, lean by default on broad lists" is a single
18
+ * ratified policy; six handlers each deciding their own default is how that
19
+ * policy silently drifts. Requirement 2 of the ticket asks for exactly one
20
+ * place with its own test, and this is it.
21
+ *
22
+ * THE `(**)` EXPRESSION IS PRIVATE
23
+ * --------------------------------
24
+ * Callers select a `projection` of `"full"` or `"lean"`. They never see or pass
25
+ * raw OCAPI select syntax. That keeps the tool surface stable if the expression
26
+ * ever changes, and — more importantly — stops an agent from constructing an
27
+ * arbitrary select expression against a live instance.
28
+ *
29
+ * EVERY CATEGORY HERE WAS VERIFIED LIVE
30
+ * -------------------------------------
31
+ * The category table is not documentation-sourced. Each entry was probed
32
+ * against a developer sandbox (OCAPI Data API v25_6) before it was written, and
33
+ * the observed key-count expansion is recorded next to it. This matters because
34
+ * an unverified projection flag is worse than no flag: it reads as a capability
35
+ * and delivers nothing.
36
+ *
37
+ * `system_object_get` is deliberately ABSENT from this policy. Its endpoint,
38
+ * GET /system_object_definitions/{type}, already returns its complete 13-field
39
+ * document and ignores `select` entirely (verified: 13 keys with and without).
40
+ * Giving it a `projection` flag would ship a parameter with no observable
41
+ * effect — the same defect BAPI-760 removed from that very tool when it deleted
42
+ * the no-op `expand_attribute_definitions` boolean. A tool that cannot honor a
43
+ * projection does not get a projection input.
44
+ */
45
+ import { z } from "zod";
46
+ /** The two projection values, as a runtime-checkable list. */
47
+ export const SFCC_READ_PROJECTIONS = ["full", "lean"];
48
+ // ---------------------------------------------------------------------------
49
+ // The policy
50
+ // ---------------------------------------------------------------------------
51
+ /**
52
+ * OCAPI's full-projection expression.
53
+ *
54
+ * Intentionally NOT exported. Only `selectExpressionFor` may map a projection
55
+ * onto it, so there is exactly one place where an outbound `select` value can
56
+ * originate.
57
+ */
58
+ const FULL_SELECT_EXPRESSION = "(**)";
59
+ /**
60
+ * The single endpoint-category → projection policy table (Requirement 2).
61
+ *
62
+ * Verified live against a developer sandbox, 2026-08-13 (OCAPI v25_6). The
63
+ * key-count expansions below are observations, not estimates:
64
+ *
65
+ * attribute_search POST .../attribute_definition_search
66
+ * system_object: 4 → 25 keys
67
+ * custom_object_def: 4 → 26 keys
68
+ * attribute_collection GET .../attribute_definitions
69
+ * custom_object_def: 4 → 26 keys
70
+ * broad_list GET /system_object_definitions
71
+ * 4 → 13 keys
72
+ * GET .../SitePreferences/attribute_groups
73
+ * 4 → 10 keys
74
+ *
75
+ * The ratified requirement these defaults encode: complete by default on
76
+ * attribute reads, lean by default on broad lists, caller can override in
77
+ * either direction. An opt-in-only flag was explicitly rejected — the caller
78
+ * who does not know to ask is precisely the caller the report described.
79
+ */
80
+ const PROJECTION_POLICY = {
81
+ attribute_search: {
82
+ defaultProjection: "full",
83
+ placement: "body",
84
+ inputDescription: "Detail level. Default: full (value_type, mandatory, localizable, searchable, site_specific); lean returns ids only.",
85
+ },
86
+ attribute_collection: {
87
+ defaultProjection: "full",
88
+ placement: "query",
89
+ inputDescription: "Detail level. Default: full (value_type, mandatory, localizable, searchable, site_specific); lean returns ids only.",
90
+ },
91
+ broad_list: {
92
+ defaultProjection: "lean",
93
+ placement: "query",
94
+ inputDescription: "Detail level. Default: lean (ids only); full returns all fields.",
95
+ },
96
+ };
97
+ // ---------------------------------------------------------------------------
98
+ // Resolution
99
+ // ---------------------------------------------------------------------------
100
+ /**
101
+ * Resolve the effective projection for a category.
102
+ *
103
+ * An omitted / undefined request resolves to the category default. An explicit
104
+ * caller value always wins — including an explicit `"lean"` on a full-default
105
+ * category, which is the "override in either direction" half of the
106
+ * requirement and is easy to lose to a truthiness check.
107
+ */
108
+ export function resolveReadProjection(category, requested) {
109
+ return requested ?? PROJECTION_POLICY[category].defaultProjection;
110
+ }
111
+ /** The wire placement (`body` or `query`) for a category. */
112
+ export function projectionPlacementFor(category) {
113
+ return PROJECTION_POLICY[category].placement;
114
+ }
115
+ /**
116
+ * Map an effective projection onto an OCAPI `select` expression.
117
+ *
118
+ * Returns `undefined` for `lean` — deliberately, so `lean` means "send no
119
+ * `select` at all" rather than "send an empty one". OCAPI's unqualified
120
+ * response is the lean shape; there is no lean expression to construct.
121
+ */
122
+ export function selectExpressionFor(projection) {
123
+ return projection === "full" ? FULL_SELECT_EXPRESSION : undefined;
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Application helpers
127
+ // ---------------------------------------------------------------------------
128
+ /**
129
+ * Apply the resolved projection to an OCAPI search POST body.
130
+ *
131
+ * Mutates and returns `body` so a handler can build its body normally and then
132
+ * hand it here as the last step. A `lean` resolution leaves the body untouched
133
+ * — no `select` key is added.
134
+ *
135
+ * Throws if the category is not body-placed, rather than silently writing a
136
+ * `select` into a body OCAPI would ignore for that endpoint.
137
+ */
138
+ export function applyProjectionToBody(body, category, requested) {
139
+ if (projectionPlacementFor(category) !== "body") {
140
+ throw new Error(`SFCC read category "${category}" carries its projection in the query string, not the POST body.`);
141
+ }
142
+ const expression = selectExpressionFor(resolveReadProjection(category, requested));
143
+ if (expression !== undefined) {
144
+ body.select = expression;
145
+ }
146
+ return body;
147
+ }
148
+ /**
149
+ * Query-string entries carrying the resolved projection.
150
+ *
151
+ * Returns an empty object for `lean`, so a caller can spread it unconditionally
152
+ * into its query params without a conditional at each call site.
153
+ *
154
+ * Throws if the category is body-placed, for the same reason as above.
155
+ */
156
+ export function projectionQueryEntries(category, requested) {
157
+ if (projectionPlacementFor(category) !== "query") {
158
+ throw new Error(`SFCC read category "${category}" carries its projection in the POST body, not the query string.`);
159
+ }
160
+ const expression = selectExpressionFor(resolveReadProjection(category, requested));
161
+ return expression === undefined ? {} : { select: expression };
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // Input schema
165
+ // ---------------------------------------------------------------------------
166
+ /**
167
+ * The optional `projection` Zod field for a category's input schema.
168
+ *
169
+ * The description is category-specific because the default differs by category,
170
+ * and the default is decision-critical *before* invocation — an agent choosing
171
+ * whether to pass `projection` cannot learn the answer from the response. It is
172
+ * kept to the fewest words that convey the default and the override, because
173
+ * every tool description and schema competes for the same tools/list token
174
+ * budget (see `integration/budget-config.ts`).
175
+ */
176
+ export function projectionInputFor(category) {
177
+ return z
178
+ .enum(["full", "lean"])
179
+ .optional()
180
+ .describe(PROJECTION_POLICY[category].inputDescription);
181
+ }