@salesforce/graphiti 11.15.0 → 11.16.0

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 (36) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/intent/build-aggregate.js +6 -0
  3. package/dist/intent/build-aggregate.js.map +1 -1
  4. package/dist/intent/build-output.d.ts +15 -1
  5. package/dist/intent/build-output.js +15 -1
  6. package/dist/intent/build-output.js.map +1 -1
  7. package/dist/lib/errors.d.ts +54 -1
  8. package/dist/lib/errors.js +104 -8
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/introspect.js +10 -3
  11. package/dist/lib/introspect.js.map +1 -1
  12. package/dist/lib/prime-schema.d.ts +10 -0
  13. package/dist/lib/prime-schema.js +31 -4
  14. package/dist/lib/prime-schema.js.map +1 -1
  15. package/dist/lib/query-builder.d.ts +11 -0
  16. package/dist/lib/query-builder.js +174 -12
  17. package/dist/lib/query-builder.js.map +1 -1
  18. package/dist/mcp/tools/sf-gql-connect.js +1 -1
  19. package/dist/mcp/tools/sf-gql-connect.js.map +1 -1
  20. package/dist/schemas/tool-adapter.d.ts +17 -1
  21. package/dist/schemas/tool-adapter.js +29 -6
  22. package/dist/schemas/tool-adapter.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/intent/__tests__/build-aggregate.spec.ts +24 -0
  25. package/src/intent/__tests__/build-output.spec.ts +24 -1
  26. package/src/intent/build-aggregate.ts +6 -0
  27. package/src/intent/build-output.ts +15 -1
  28. package/src/lib/__tests__/query-builder.spec.ts +388 -0
  29. package/src/lib/errors.ts +125 -2
  30. package/src/lib/introspect.ts +10 -2
  31. package/src/lib/prime-schema.ts +32 -4
  32. package/src/lib/query-builder.ts +176 -11
  33. package/src/mcp/tools/__tests__/error-surface.contract.spec.ts +50 -1
  34. package/src/mcp/tools/sf-gql-connect.ts +1 -1
  35. package/src/schemas/__tests__/tool-adapter.spec.ts +162 -1
  36. package/src/schemas/tool-adapter.ts +51 -7
@@ -4,12 +4,24 @@
4
4
  * For full license text, see the LICENSE.txt file
5
5
  */
6
6
 
7
- import { GRAPHQL_NAME_RE } from "./graphql-name.js";
7
+ import { UserInputError } from "./errors.js";
8
+ import { assertGraphqlName, GRAPHQL_NAME_RE } from "./graphql-name.js";
8
9
  import type { QuerySession, ProjectionNode, DirectiveNode } from "./session.js";
9
10
  import { getChildren, getEffectiveArgs } from "./session.js";
10
11
 
11
12
  /**
12
13
  * Renders a QuerySession's selection tree into a properly formatted GraphQL query string.
14
+ *
15
+ * @throws if any emitted GraphQL Name (operation name, variable name, field
16
+ * name, alias, inline-fragment type condition, or directive name) is not a
17
+ * valid GraphQL Name, if a variable's type reference has a non-Name innermost
18
+ * NamedType, or if a `{`/`[`-prefixed argument/default value is not valid JSON
19
+ * — the W-23204027 render-layer fail-safe. This fires only on a programmer
20
+ * error (a builder/CLI path that stored a raw identifier without its own guard)
21
+ * or a hostile input that slipped past the per-builder guards; every legitimate
22
+ * value passes. Name violations throw an `Error` whose message matches
23
+ * USER_INPUT_RE; the JSON-literal violation throws a typed `UserInputError` —
24
+ * both classify as UserInput at the MCP boundary (`runTool`).
13
25
  */
14
26
  export function renderQuery(session: QuerySession): string {
15
27
  const parts: string[] = [];
@@ -19,14 +31,41 @@ export function renderQuery(session: QuerySession): string {
19
31
  session.variables.length > 0
20
32
  ? `(${session.variables
21
33
  .map((v) => {
34
+ // Fail-safe: the variable NAME is a GraphQL Name position emitted
35
+ // verbatim as `$<name>`. The TYPE is also emitted verbatim, but it
36
+ // is not a bare Name — it carries type-reference syntax (`!`, `[]`)
37
+ // — so it is guarded structurally by assertGraphqlType, which walks
38
+ // the wrappers and asserts only the innermost NamedType.
39
+ assertGraphqlName(v.name, "renderQuery", "variableName");
40
+ assertGraphqlType(v.type, "renderQuery");
22
41
  let def = `$${v.name}: ${v.type}`;
23
- if (v.defaultValue !== undefined) def += ` = ${v.defaultValue}`;
42
+ // W-23204027 (PR #694 review): the default VALUE is a value position,
43
+ // not a Name — it must be formatted like any other arg value, not
44
+ // concatenated raw. Raw emission both breaks legitimate output (a
45
+ // multi-word string default renders `= Acme Corp`, which fails to
46
+ // parse) and is a live selection-set/operation injection sink: a
47
+ // default of `5) { stolen { Id } } query Decoy($z: Int` (reachable
48
+ // via `sf_gql_raw`'s `var $x <path> '<default>'`) would otherwise
49
+ // render a second, attacker-controlled operation. formatArgValue
50
+ // quotes strings, passes through numbers/enums/bools/`$refs`, and
51
+ // routes `{`/`[` defaults through jsonToGraphQL, which either emits
52
+ // an arg-key-guarded input-object literal (valid JSON) or throws
53
+ // (invalid JSON — see Round 3 fix in jsonToGraphQL). No variant
54
+ // reaches raw emission.
55
+ if (v.defaultValue !== undefined) def += ` = ${formatArgValue(v.defaultValue)}`;
24
56
  return def;
25
57
  })
26
58
  .join(", ")})`
27
59
  : "";
28
60
 
29
61
  const operationKeyword = session.operation === "aggregate" ? "query" : session.operation;
62
+ // Fail-safe: operationName is a GraphQL Name position emitted verbatim in the
63
+ // operation header. Guarded at every builder, but a deserialized/migrated
64
+ // session (loadSession reads it from disk with no re-validation) or a future
65
+ // builder could bypass that — so backstop it here too.
66
+ if (session.operationName) {
67
+ assertGraphqlName(session.operationName, "renderQuery", "operationName");
68
+ }
30
69
  const operationName = session.operationName ? ` ${session.operationName}` : "";
31
70
  const operationBody = renderChildren(session, null, 1);
32
71
  if (operationBody) {
@@ -40,6 +79,63 @@ export function renderQuery(session: QuerySession): string {
40
79
  return parts.join("\n");
41
80
  }
42
81
 
82
+ /**
83
+ * W-23204027 render-layer fail-safe for GraphQL argument KEYS. An argument key
84
+ * is emitted verbatim as `<key>: <value>` in three places — a field's argument
85
+ * names (renderField), a directive's argument names (renderDirective), and the
86
+ * keys of a nested input-object literal (valueToGraphQL). The last is
87
+ * attacker-reachable today: `sf_gql_list` / `sf_gql_aggregate` accept a `filter`
88
+ * / `orderBy` typed as `z.record(z.unknown())` (no charset on keys), the builder
89
+ * `JSON.stringify`s it into an arg value, and valueToGraphQL then emits each
90
+ * input field name / operator as a key. A key such as
91
+ * `Name: {eq:"x"} }) { edges { node { Id } } } evilAlias: accounts(where: { Industry`
92
+ * would otherwise render a fully parseable, schema-valid second connection —
93
+ * a silent selection-set injection. Keys are NOT validated at the builder or
94
+ * zod layer, so this render-layer assert is the only universal choke point
95
+ * (everything funnels through the renderer). Every legitimate key — operators
96
+ * (eq/ne/and/or/not/…), SObject field API names incl. `Custom__c`, connection
97
+ * args — is a valid GraphQL Name, so this never fires on real input.
98
+ */
99
+ function assertArgumentKey(key: string, emitter: string): void {
100
+ assertGraphqlName(key, emitter, "argumentKey");
101
+ }
102
+
103
+ /**
104
+ * W-23204027 render-layer fail-safe for a variable's TYPE reference. Unlike a
105
+ * Name position, a type carries GraphQL type-reference syntax (`!` for
106
+ * non-null, `[...]` for lists) and so cannot be a bare `assertGraphqlName` —
107
+ * that would wrongly reject legitimate types like `Int!` or `[ID!]!`. Instead
108
+ * we walk the type-reference grammar structurally (October 2021 §2.11): strip a
109
+ * trailing non-null `!`, unwrap a `[ ... ]` list wrapper (recursing on the inner
110
+ * type), and finally assert the innermost NamedType is a valid GraphQL Name.
111
+ *
112
+ * This is a defense-in-depth backstop, not a live-reachable sink today: every
113
+ * `addVariable` call site derives the type from schema inference,
114
+ * `createInputTypeName`, or a hardcoded scalar — none accept a raw type from the
115
+ * agent (the CLI `var`/`define` verbs set only the NAME, never the type). It
116
+ * guards the residual paths a Name backstop would otherwise miss: a
117
+ * deserialized/migrated session (`loadSession` does no type re-validation) or a
118
+ * future builder that stores a raw type. Without it, a type such as
119
+ * `Int) { evil { id } } query Decoy($z: Int` emitted verbatim as `$v: <type>`
120
+ * breaks out into a second operation with no default value needed.
121
+ */
122
+ function assertGraphqlType(type: string, emitter: string): void {
123
+ let inner = type.trim();
124
+ // Peel any number of non-null / list wrappers from the outside in.
125
+ while (true) {
126
+ if (inner.endsWith("!")) {
127
+ inner = inner.slice(0, -1).trim();
128
+ continue;
129
+ }
130
+ if (inner.startsWith("[") && inner.endsWith("]")) {
131
+ inner = inner.slice(1, -1).trim();
132
+ continue;
133
+ }
134
+ break;
135
+ }
136
+ assertGraphqlName(inner, emitter, "variableType");
137
+ }
138
+
43
139
  function renderChildren(session: QuerySession, parentId: string | null, depth: number): string {
44
140
  const _indent = " ".repeat(depth);
45
141
  const lines: string[] = [];
@@ -63,17 +159,31 @@ function renderField(
63
159
  const indent = " ".repeat(depth);
64
160
  let line = indent;
65
161
 
66
- // Alias
162
+ // W-23204027 render-layer fail-safe: assert every emitted GraphQL Name is
163
+ // valid, making "the renderer never emits an injectable identifier" a
164
+ // system-wide invariant layered UNDER the per-builder guards (W-22735537),
165
+ // not replacing them. Fires only on a programmer error — a future builder
166
+ // that calls selectLeaf/selectDottedFieldPath and forgets its assert — so
167
+ // failing loud is correct. The message matches USER_INPUT_RE, so the MCP
168
+ // path classifies it as UserInput rather than crashing. Argument *keys* are
169
+ // covered too: they are emitted verbatim as `<key>: <value>` here and in
170
+ // renderDirective/valueToGraphQL, so a malicious filter/orderBy key (a
171
+ // z.record(z.unknown()) with no charset at the schema boundary — see
172
+ // assertArgumentKey) can otherwise break out of the argument object into
173
+ // the selection set. That sink is NOT closed at the builder or zod layer.
67
174
  if (node.alias) {
175
+ assertGraphqlName(node.alias, "renderField", "alias");
68
176
  line += `${node.alias}: `;
69
177
  }
70
178
 
179
+ assertGraphqlName(node.fieldName, "renderField", "fieldName");
71
180
  line += node.fieldName;
72
181
 
73
182
  // Arguments
74
183
  const argEntries = Object.entries(getEffectiveArgs(session, node));
75
184
  if (argEntries.length > 0) {
76
185
  const argParts = argEntries.map(([name, value]) => {
186
+ assertArgumentKey(name, "renderField");
77
187
  return `${name}: ${formatArgValue(value)}`;
78
188
  });
79
189
  line += `(${argParts.join(", ")})`;
@@ -102,6 +212,9 @@ function renderInlineFragment(
102
212
  depth: number,
103
213
  ): string {
104
214
  const indent = " ".repeat(depth);
215
+ // W-23204027 render-layer fail-safe (see renderField): a type condition is a
216
+ // GraphQL Name position emitted verbatim after `... on `.
217
+ assertGraphqlName(frag.onType, "renderInlineFragment", "onType");
105
218
  let line = `${indent}... on ${frag.onType}`;
106
219
 
107
220
  for (const dir of frag.directives) {
@@ -119,11 +232,19 @@ function renderInlineFragment(
119
232
  }
120
233
 
121
234
  function renderDirective(dir: DirectiveNode): string {
235
+ // W-23204027 render-layer fail-safe (see renderField): a directive name is a
236
+ // GraphQL Name position emitted verbatim after `@`. The only directive the
237
+ // declarative/MCP surface adds is `@optional` (a valid Name), so this never
238
+ // fires on legitimate output.
239
+ assertGraphqlName(dir.name, "renderDirective", "directiveName");
122
240
  const argEntries = Object.entries(dir.args);
123
241
  if (argEntries.length === 0) {
124
242
  return `@${dir.name}`;
125
243
  }
126
- const argParts = argEntries.map(([name, value]) => `${name}: ${formatArgValue(value)}`);
244
+ const argParts = argEntries.map(([name, value]) => {
245
+ assertArgumentKey(name, "renderDirective");
246
+ return `${name}: ${formatArgValue(value)}`;
247
+ });
127
248
  return `@${dir.name}(${argParts.join(", ")})`;
128
249
  }
129
250
 
@@ -153,8 +274,21 @@ function formatArgValue(value: string): string {
153
274
  return jsonToGraphQL(trimmed);
154
275
  }
155
276
 
156
- // Quoted string — pass through
157
- if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed;
277
+ // Quoted string — pass through ONLY when it is a single, well-formed string
278
+ // literal. W-23204027 (PR #694 review): a bare `startsWith('"') && endsWith('"')`
279
+ // check is an injection hole — a payload like
280
+ // `"a") { stolen } query Y($q: String = "b"` also starts and ends with a quote
281
+ // yet breaks out of the value into a second operation. JSON.parse yielding a
282
+ // string proves the whole token is ONE literal (interior quotes are escaped);
283
+ // anything else falls through to JSON.stringify, which re-encodes it as a safe
284
+ // single literal.
285
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
286
+ try {
287
+ if (typeof JSON.parse(trimmed) === "string") return trimmed;
288
+ } catch {
289
+ // Not a single well-formed literal — re-encode below.
290
+ }
291
+ }
158
292
 
159
293
  // Default: a string literal. JSON.stringify produces a spec-valid GraphQL string
160
294
  // literal — it escapes line terminators and control chars (\n \r \t \b \f) and
@@ -169,13 +303,35 @@ function formatArgValue(value: string): string {
169
303
  * GraphQL uses unquoted keys: { Status: { ne: "Closed" } }
170
304
  */
171
305
  function jsonToGraphQL(jsonStr: string): string {
306
+ let parsed: unknown;
172
307
  try {
173
- const parsed = JSON.parse(jsonStr);
174
- return valueToGraphQL(parsed);
308
+ parsed = JSON.parse(jsonStr);
175
309
  } catch {
176
- // If not valid JSON, return as-is (might already be in GraphQL format)
177
- return jsonStr;
310
+ // W-23204027 (PR #694 review, Round 3): REJECT do NOT return the raw
311
+ // string. `formatArgValue` only routes here when the value starts with `{`
312
+ // or `[`, so a JSON.parse failure means it is a `{`/`[`-prefixed string
313
+ // that is NOT well-formed JSON — never legitimate GraphQL. Every real
314
+ // producer of a `{`/`[` value delivers valid JSON (the builders
315
+ // `JSON.stringify` filter/orderBy; the CLI `set`/`assign` path
316
+ // JSON-validates `{`/`[` literals in `validateLiteralAssignment` before
317
+ // storing), so this rejects zero legitimate flows. Returning it verbatim
318
+ // bypassed valueToGraphQL's `assertArgumentKey`, making it a live
319
+ // selection-set/operation injection sink: a variable default of
320
+ // `{ minRevenue: 0 }) { edges { node { id } } } } query Decoy($z: Filter`
321
+ // (unquoted key ⇒ invalid JSON ⇒ this catch; reachable via `sf_gql_raw`'s
322
+ // `var $x <path> '<default>'`) rendered a second, attacker-controlled
323
+ // operation. Throw a typed UserInputError (NOT a bare Error whose text
324
+ // would miss USER_INPUT_RE and misclassify as Internal) so `runTool`
325
+ // classifies it UserInput.
326
+ throw new UserInputError(
327
+ `jsonToGraphQL: value beginning with '{' or '[' is not valid JSON and cannot be rendered as a GraphQL literal: ${jsonStr.slice(0, 60)}`,
328
+ );
178
329
  }
330
+ // valueToGraphQL runs OUTSIDE the try: it enforces the W-23204027 arg-key
331
+ // fail-safe by throwing on a malicious input-object key, and that assertion
332
+ // must propagate — not be swallowed and fall back to emitting the raw
333
+ // (injectable) string.
334
+ return valueToGraphQL(parsed);
179
335
  }
180
336
 
181
337
  function valueToGraphQL(value: unknown): string {
@@ -199,7 +355,16 @@ function valueToGraphQL(value: unknown): string {
199
355
  }
200
356
  if (typeof value === "object") {
201
357
  const entries = Object.entries(value as Record<string, unknown>);
202
- const parts = entries.map(([k, v]) => `${k}: ${valueToGraphQL(v)}`);
358
+ const parts = entries.map(([k, v]) => {
359
+ // The live arg-key injection sink: filter/orderBy objects are
360
+ // JSON.stringify'd into an arg value by the builders, so every input
361
+ // field name / operator here is an attacker-controllable key emitted
362
+ // verbatim into a GraphQL input-object literal. Guard it (see
363
+ // assertArgumentKey). Array elements never reach this branch as keys —
364
+ // the Array case above emits `[...]` with no `<key>:`.
365
+ assertArgumentKey(k, "valueToGraphQL");
366
+ return `${k}: ${valueToGraphQL(v)}`;
367
+ });
203
368
  return `{ ${parts.join(", ")} }`;
204
369
  }
205
370
  return String(value);
@@ -224,7 +224,8 @@ describe("mcp/tools error surface — category prefixes (contract)", () => {
224
224
 
225
225
  it("Schema: an introspection/priming failure surfaces with the Schema prefix", async () => {
226
226
  // No prior disk cache (fresh GRAPHITI_HOME) + a download that throws an
227
- // untyped error → primeSchemaWithLock wraps it as SchemaError.
227
+ // untyped error WITH NO structured cause → primeSchemaWithLock wraps it as
228
+ // SchemaError and classifyCause(undefined) → "no", so no retry token.
228
229
  const primeDeps: PrimeDeps = {
229
230
  ...makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA),
230
231
  downloadSchema: async () => {
@@ -239,6 +240,54 @@ describe("mcp/tools error surface — category prefixes (contract)", () => {
239
240
  });
240
241
  expect(result.isError).toBe(true);
241
242
  expect(errorText(result)).toMatch(/^Schema: /);
243
+ // An untyped cause-less failure is treated as permanent (no token).
244
+ expect(errorText(result)).not.toMatch(/\[retry=/);
245
+ } finally {
246
+ await client.close();
247
+ await server.close();
248
+ }
249
+ });
250
+
251
+ it("Schema (retry=backoff): a transient network cause surfaces a backoff token end-to-end", async () => {
252
+ // W-23148365: a download failure whose cause carries a transient errno is
253
+ // wrapped as SchemaError({ retry: classifyCause(cause) }) → "backoff".
254
+ const primeDeps: PrimeDeps = {
255
+ ...makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA),
256
+ downloadSchema: async () => {
257
+ throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" });
258
+ },
259
+ };
260
+ const { client, server } = await connectWith(primeDeps);
261
+ try {
262
+ const result = await client.callTool({
263
+ name: "sf_gql_list",
264
+ arguments: { org: ORG, object: "Account", fields: ["Id"] },
265
+ });
266
+ expect(result.isError).toBe(true);
267
+ expect(errorText(result)).toMatch(/^Schema: /);
268
+ expect(errorText(result)).toMatch(/ \[retry=backoff\]$/);
269
+ } finally {
270
+ await client.close();
271
+ await server.close();
272
+ }
273
+ });
274
+
275
+ it("Schema (permanent): a 4xx cause surfaces NO retry token end-to-end", async () => {
276
+ const primeDeps: PrimeDeps = {
277
+ ...makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA),
278
+ downloadSchema: async () => {
279
+ throw Object.assign(new Error("not found"), { statusCode: 404 });
280
+ },
281
+ };
282
+ const { client, server } = await connectWith(primeDeps);
283
+ try {
284
+ const result = await client.callTool({
285
+ name: "sf_gql_list",
286
+ arguments: { org: ORG, object: "Account", fields: ["Id"] },
287
+ });
288
+ expect(result.isError).toBe(true);
289
+ expect(errorText(result)).toMatch(/^Schema: /);
290
+ expect(errorText(result)).not.toMatch(/\[retry=/);
242
291
  } finally {
243
292
  await client.close();
244
293
  await server.close();
@@ -21,7 +21,7 @@ export function registerSfGqlConnectTool(
21
21
  "sf_gql_connect",
22
22
  {
23
23
  description:
24
- "Connect to a Salesforce org and prime its GraphQL schema cache. With forceRefresh, re-download the schema and coherently clear all caches so subsequent tools see freshly-deployed metadata. Concurrent refreshes coalesce into a single introspection. Returns { org, instanceUrl, refreshed, cached, durationMs, warnings? } — not the standard ToolOutput envelope. If a refresh fails but a usable cached schema survives, returns refreshed:false with a staleness warning instead of erroring. Error convention (all sf_gql_* tools): on failure the isError text is prefixed with a category — `UserInput:` (fix the request), `Auth:` (re-authenticate the org), `Schema:` (introspection/cache problem), or `Internal:` (unexpected) — so you can decide whether to fix inputs, re-auth, or retry.",
24
+ "Connect to a Salesforce org and prime its GraphQL schema cache. With forceRefresh, re-download the schema and coherently clear all caches so subsequent tools see freshly-deployed metadata. Concurrent refreshes coalesce into a single introspection. Returns { org, instanceUrl, refreshed, cached, durationMs, warnings? } — not the standard ToolOutput envelope. If a refresh fails but a usable cached schema survives, returns refreshed:false with a staleness warning instead of erroring. Error convention (all sf_gql_* tools): on failure the isError text is prefixed with a category — `UserInput:` (fix the request), `Auth:` (re-authenticate the org), `Schema:` (introspection/cache problem), or `Internal:` (unexpected) — so you can decide whether to fix inputs, re-auth, or retry. A `Schema:` error additionally ends with a retryability token: `[retry=now]` (retry immediately — e.g. a priming-lock timeout, where no live org round-trip occurred) or `[retry=backoff]` (the org was unreachable and an automatic retry already failed — wait with increasing backoff, e.g. 2^n seconds capped around 8s, and retry serially; do not fan out concurrent retries against an org that is already failing, and note some conditions such as an org API rate limit may take longer than a few seconds to clear); ABSENCE of any `[retry=...]` token means the failure is permanent (404, missing/malformed `__schema`, GraphQL errors in the body, or no cached schema) — do not retry, fix the request or re-prime via sf_gql_connect. This token convention applies to every sf_gql_* tool, not just this one. Example error text: `Schema: introspection request failed [retry=backoff]`; extract the disposition with the end-anchored regex ` /\\s\\[retry=(now|backoff)\\]$/` (no match ⇒ permanent).",
25
25
  inputSchema,
26
26
  },
27
27
  async (args) => runTool(() => buildConnect(args, opts)),
@@ -6,7 +6,7 @@
6
6
 
7
7
  import os from "node:os";
8
8
  import { afterEach, describe, expect, it, vi } from "vitest";
9
- import { AuthError, SchemaError, UserInputError } from "../../lib/errors.js";
9
+ import { AuthError, classifyCause, SchemaError, UserInputError } from "../../lib/errors.js";
10
10
  import { SchemaRefreshError } from "../../lib/prime-schema.js";
11
11
  import { MutationContextError } from "../../lib/walker.js";
12
12
  import { classifyError, neutralizeControlChars, PATH_MARKERS, runTool } from "../tool-adapter.js";
@@ -49,6 +49,21 @@ describe("schemas/tool-adapter — classifyError", () => {
49
49
  const r = classifyError(new SchemaError("buildList: is not a valid GraphQL Name"));
50
50
  expect(r.category).toBe("Schema");
51
51
  });
52
+
53
+ // W-23204027 (PR #694 review, Round 3): the render-layer jsonToGraphQL
54
+ // catch-sink fix throws a *typed* UserInputError. This pins WHY it must be
55
+ // typed and not a bare Error: its message ("… is not valid JSON and cannot
56
+ // be rendered as a GraphQL literal") is NOT anchored by USER_INPUT_RE, so a
57
+ // bare Error carrying it would fall through to Internal — misclassifying a
58
+ // user-input mistake and hiding an injection attempt in the operator log.
59
+ it("routes the jsonToGraphQL literal-rejection UserInputError to UserInput", () => {
60
+ const msg =
61
+ "jsonToGraphQL: value beginning with '{' or '[' is not valid JSON and cannot be rendered as a GraphQL literal: { minRevenue: 0 })";
62
+ expect(classifyError(new UserInputError(msg)).category).toBe("UserInput");
63
+ // The same message on a BARE Error is NOT anchored by USER_INPUT_RE —
64
+ // this is the exact trap the typed throw avoids.
65
+ expect(classifyError(new Error(msg)).category).toBe("Internal");
66
+ });
52
67
  });
53
68
 
54
69
  describe("heuristic routing (untyped fallbacks)", () => {
@@ -443,3 +458,149 @@ describe("schemas/tool-adapter — runTool", () => {
443
458
  expect(spy).not.toHaveBeenCalled();
444
459
  });
445
460
  });
461
+
462
+ describe("schemas/tool-adapter — Schema retryability hint (W-23148365)", () => {
463
+ afterEach(() => {
464
+ vi.restoreAllMocks();
465
+ });
466
+
467
+ describe("classifyCause — the new cause-inspection logic", () => {
468
+ it("maps transient HTTP statuses to backoff", () => {
469
+ for (const statusCode of [420, 429, 500, 502, 503, 504]) {
470
+ expect(classifyCause({ statusCode })).toBe("backoff");
471
+ }
472
+ });
473
+
474
+ it("maps deterministic 4xx statuses to no", () => {
475
+ for (const statusCode of [400, 401, 403, 404, 409, 422]) {
476
+ expect(classifyCause({ statusCode })).toBe("no");
477
+ }
478
+ });
479
+
480
+ it("maps transient network errnos (org round-trip failures) to backoff", () => {
481
+ for (const code of ["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ECONNREFUSED", "EPIPE"]) {
482
+ expect(classifyCause({ code })).toBe("backoff");
483
+ }
484
+ });
485
+
486
+ it("maps permanent errnos (wrong host, no perms) to no", () => {
487
+ for (const code of ["ENOTFOUND", "EACCES", "EROFS", "ENOENT"]) {
488
+ expect(classifyCause({ code })).toBe("no");
489
+ }
490
+ });
491
+
492
+ // Review #2: local resource-exhaustion errnos come from the cache write
493
+ // (atomicWriteJson), which shares the download try — they are NOT org
494
+ // round-trips, so a `backoff` ("org unreachable, wait 1-2s") hint would be
495
+ // wrong and mask an ops problem. They must classify as `no`.
496
+ it("maps local resource-exhaustion errnos (disk full / fd exhaustion) to no", () => {
497
+ for (const code of ["ENOSPC", "EMFILE", "EAGAIN"]) {
498
+ expect(classifyCause({ code })).toBe("no");
499
+ }
500
+ });
501
+
502
+ it("parses ERROR_HTTP_<nnn> from errorCode/name and REQUEST_LIMIT_EXCEEDED", () => {
503
+ expect(classifyCause({ errorCode: "ERROR_HTTP_502" })).toBe("backoff");
504
+ expect(classifyCause({ name: "ERROR_HTTP_503" })).toBe("backoff");
505
+ expect(classifyCause({ errorCode: "ERROR_HTTP_404" })).toBe("no");
506
+ expect(classifyCause({ errorCode: "REQUEST_LIMIT_EXCEEDED" })).toBe("backoff");
507
+ });
508
+
509
+ it("defaults to no for an absent/unrecognized cause (never invents retryability)", () => {
510
+ expect(classifyCause(undefined)).toBe("no");
511
+ expect(classifyCause(null)).toBe("no");
512
+ expect(classifyCause("a bare string")).toBe("no");
513
+ expect(classifyCause({ unrelated: true })).toBe("no");
514
+ expect(classifyCause({ statusCode: 418 })).toBe("no");
515
+ });
516
+
517
+ // Defensive: a cause with a throwing accessor must not escape (classifyCause
518
+ // runs inside runTool's catch; an escaped throw would drop the sanitized
519
+ // envelope). It falls back to the conservative "no" default.
520
+ it("returns no for a cause whose property accessor throws (no escape)", () => {
521
+ const booby = {};
522
+ Object.defineProperty(booby, "statusCode", {
523
+ get() {
524
+ throw new Error("boom");
525
+ },
526
+ enumerable: true,
527
+ });
528
+ expect(() => classifyCause(booby)).not.toThrow();
529
+ expect(classifyCause(booby)).toBe("no");
530
+ });
531
+ });
532
+
533
+ describe("classifyError().retry", () => {
534
+ it("reads the stamped retry off a typed SchemaError", () => {
535
+ expect(classifyError(new SchemaError("lock timeout", { retry: "now" })).retry).toBe("now");
536
+ expect(classifyError(new SchemaError("priming failed", { retry: "backoff" })).retry).toBe(
537
+ "backoff",
538
+ );
539
+ expect(classifyError(new SchemaError("no cache", { retry: "no" })).retry).toBe("no");
540
+ });
541
+
542
+ it("falls back to the cause chain for an untyped throw routed to Schema", () => {
543
+ // Untyped Error whose message matches SCHEMA_RE, carrying a transient cause.
544
+ const e = new Error("No cached schema after socket hang up");
545
+ (e as { cause?: unknown }).cause = { statusCode: 503 };
546
+ const r = classifyError(e);
547
+ expect(r.category).toBe("Schema");
548
+ expect(r.retry).toBe("backoff");
549
+ });
550
+
551
+ it("a bare SchemaError (no stamp, no cause) defaults to no — regression guard for existing cases", () => {
552
+ expect(classifyError(new SchemaError("No cached schema for org")).retry).toBe("no");
553
+ });
554
+
555
+ it("a SchemaRefreshError carries its stamped disposition", () => {
556
+ const transient = new SchemaRefreshError("refresh failed; keeping cache", {
557
+ instanceUrl: "https://x.my.salesforce.com",
558
+ staleSince: "2026-06-29T00:00:00.000Z",
559
+ retry: "backoff",
560
+ });
561
+ expect(classifyError(transient).retry).toBe("backoff");
562
+ });
563
+
564
+ it("non-Schema categories are uniformly no", () => {
565
+ expect(classifyError(new AuthError("Failed to get org info")).retry).toBe("no");
566
+ expect(classifyError(new UserInputError("bad input")).retry).toBe("no");
567
+ expect(classifyError(new Error("kaboom")).retry).toBe("no"); // Internal
568
+ });
569
+ });
570
+
571
+ describe("runTool token suffix", () => {
572
+ it("appends [retry=backoff] to a transient Schema error, after the Schema: prefix", async () => {
573
+ const result = await runTool(async () => {
574
+ throw new SchemaError("priming failed", { retry: "backoff" });
575
+ });
576
+ const text = result.content[0]?.text ?? "";
577
+ expect(text.startsWith("Schema: ")).toBe(true);
578
+ expect(text).toMatch(/ \[retry=backoff\]$/);
579
+ });
580
+
581
+ it("appends [retry=now] to a retry-now Schema error", async () => {
582
+ const result = await runTool(async () => {
583
+ throw new SchemaError("lock timeout", { retry: "now" });
584
+ });
585
+ expect(result.content[0]?.text).toMatch(/ \[retry=now\]$/);
586
+ });
587
+
588
+ it("appends NO token to a permanent Schema error (regression: bare 'No cached schema')", async () => {
589
+ const result = await runTool(async () => {
590
+ throw new SchemaError("No cached schema");
591
+ });
592
+ const text = result.content[0]?.text ?? "";
593
+ expect(text).toBe("Schema: No cached schema");
594
+ expect(text).not.toMatch(/\[retry=/);
595
+ });
596
+
597
+ it("never appends a token to a non-Schema category", async () => {
598
+ const result = await runTool(async () => {
599
+ throw new AuthError("Failed to get org info");
600
+ });
601
+ const text = result.content[0]?.text ?? "";
602
+ expect(text.startsWith("Auth: ")).toBe(true);
603
+ expect(text).not.toMatch(/\[retry=/);
604
+ });
605
+ });
606
+ });
@@ -5,7 +5,13 @@
5
5
  */
6
6
 
7
7
  import os from "node:os";
8
- import { AuthError, SchemaError, UserInputError } from "../lib/errors.js";
8
+ import {
9
+ AuthError,
10
+ classifyCause,
11
+ type RetryHint,
12
+ SchemaError,
13
+ UserInputError,
14
+ } from "../lib/errors.js";
9
15
  import { graphitiHome, schemaDir } from "../lib/introspect.js";
10
16
  import { SchemaRefreshError } from "../lib/prime-schema.js";
11
17
  import { MutationContextError } from "../lib/walker.js";
@@ -27,6 +33,17 @@ import { MutationContextError } from "../lib/walker.js";
27
33
  * untyped throws. The sanitized message is returned for EVERY category — not
28
34
  * just Internal — because typed Auth/Schema errors embed cache/lock paths and
29
35
  * wrapped jsforce/@salesforce/core causes embed `~/.sfdx/...` paths.
36
+ *
37
+ * Retryability hint (W-23148365): a `Schema:` error — and ONLY a `Schema:` error —
38
+ * additionally ends with a closed-set token telling the host whether to retry:
39
+ * ` [retry=now]` (retry immediately; no live org round-trip occurred — a priming-
40
+ * lock timeout, or a refresh where a usable cached schema survives), ` [retry=backoff]`
41
+ * (the introspection request failed transiently AND already exhausted the
42
+ * connection layer's one built-in retry — wait briefly), or NO token (permanent:
43
+ * 4xx, malformed/absent `__schema`, GraphQL errors in the body, no cached schema —
44
+ * don't retry; fix the request, re-auth, or re-prime). The `<Category>: ` prefix is
45
+ * unchanged. The disposition comes from the typed error's stamped `retry` field,
46
+ * with a defensive `cause`-chain fallback (`classifyCause`) for untyped throws.
30
47
  */
31
48
 
32
49
  export type ErrorCategory = "UserInput" | "Auth" | "Schema" | "Internal";
@@ -178,19 +195,42 @@ function categoryOf(e: unknown, message: string): ErrorCategory {
178
195
  return "Internal";
179
196
  }
180
197
 
181
- /** Classify a thrown error into a category + sanitized message text. */
182
- export function classifyError(e: unknown): { category: ErrorCategory; text: string } {
198
+ /**
199
+ * Retryability disposition for a Schema-category error (W-23148365). The typed
200
+ * SchemaError/SchemaRefreshError carry an authoritative `retry` stamped at the
201
+ * throw site; for an untyped throw that reached the Schema bucket via the
202
+ * SCHEMA_RE heuristic we fall back to inspecting its `cause`. Only ever called
203
+ * for the Schema category — Auth/UserInput/Internal are uniformly `"no"`.
204
+ */
205
+ function retryHintFor(e: unknown): RetryHint {
206
+ if (e instanceof SchemaError || e instanceof SchemaRefreshError) return e.retry;
207
+ if (typeof e === "object" && e !== null && "cause" in e) {
208
+ return classifyCause((e as { cause?: unknown }).cause);
209
+ }
210
+ return "no";
211
+ }
212
+
213
+ /**
214
+ * Classify a thrown error into a category, a Schema-only retry hint, and a
215
+ * sanitized message text. `retry` is `"no"` for every non-Schema category.
216
+ */
217
+ export function classifyError(e: unknown): {
218
+ category: ErrorCategory;
219
+ retry: RetryHint;
220
+ text: string;
221
+ } {
183
222
  const message = e instanceof Error ? e.message : String(e);
184
223
  const category = categoryOf(e, message);
224
+ const retry = category === "Schema" ? retryHintFor(e) : "no";
185
225
  const safeMessage = sanitizePaths(message);
186
- if (category !== "Internal") return { category, text: safeMessage };
226
+ if (category !== "Internal") return { category, retry, text: safeMessage };
187
227
 
188
228
  // Internal: unexpected. Attach a truncated, path-stripped stack for the host.
189
229
  const frames = truncatedStack(e);
190
230
  const text = frames.length
191
231
  ? `${safeMessage}\n${frames.map((f) => ` ${f}`).join("\n")}`
192
232
  : safeMessage;
193
- return { category, text };
233
+ return { category, retry, text };
194
234
  }
195
235
 
196
236
  /**
@@ -206,13 +246,17 @@ export async function runTool(fn: () => Promise<unknown>): Promise<ToolTextResul
206
246
  // carry them and JSON.stringify won't escape them (MCP TS SDK #2155).
207
247
  return { content: [{ type: "text", text: stripLineSeparators(JSON.stringify(output)) }] };
208
248
  } catch (e) {
209
- const { category, text } = classifyError(e);
249
+ const { category, retry, text } = classifyError(e);
210
250
  if (category === "Internal") {
211
251
  console.error("[graphiti-mcp] Internal tool error:", e);
212
252
  }
253
+ // Append the retry hint as an end-anchored token (W-23148365). Only a
254
+ // Schema error ever carries a non-"no" hint; the `<Category>: ` prefix is
255
+ // unchanged so existing host parses (startsWith / split) still work.
256
+ const suffix = retry === "no" ? "" : ` [retry=${retry}]`;
213
257
  return {
214
258
  isError: true,
215
- content: [{ type: "text", text: stripLineSeparators(`${category}: ${text}`) }],
259
+ content: [{ type: "text", text: stripLineSeparators(`${category}: ${text}${suffix}`) }],
216
260
  };
217
261
  }
218
262
  }