@salesforce/graphiti 10.20.2 → 10.21.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 (83) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/commands/args.js +15 -2
  3. package/dist/commands/args.js.map +1 -1
  4. package/dist/intent/build-aggregate.js +6 -2
  5. package/dist/intent/build-aggregate.js.map +1 -1
  6. package/dist/intent/build-detail.js +3 -2
  7. package/dist/intent/build-detail.js.map +1 -1
  8. package/dist/intent/build-list.js +11 -7
  9. package/dist/intent/build-list.js.map +1 -1
  10. package/dist/intent/select-child-relationship.d.ts +1 -1
  11. package/dist/intent/select-child-relationship.js +3 -3
  12. package/dist/intent/select-child-relationship.js.map +1 -1
  13. package/dist/lib/auth.js +3 -2
  14. package/dist/lib/auth.js.map +1 -1
  15. package/dist/lib/errors.d.ts +38 -0
  16. package/dist/lib/errors.js +42 -0
  17. package/dist/lib/errors.js.map +1 -0
  18. package/dist/lib/introspect.js +4 -3
  19. package/dist/lib/introspect.js.map +1 -1
  20. package/dist/lib/prime-schema.js +13 -5
  21. package/dist/lib/prime-schema.js.map +1 -1
  22. package/dist/lib/query-builder.js +19 -6
  23. package/dist/lib/query-builder.js.map +1 -1
  24. package/dist/lib/session.d.ts +10 -1
  25. package/dist/lib/session.js +9 -2
  26. package/dist/lib/session.js.map +1 -1
  27. package/dist/lib/variable-promotion.js +7 -3
  28. package/dist/lib/variable-promotion.js.map +1 -1
  29. package/dist/lib/walker.js +8 -6
  30. package/dist/lib/walker.js.map +1 -1
  31. package/dist/mcp/tools/sf-gql-aggregate.js +2 -6
  32. package/dist/mcp/tools/sf-gql-aggregate.js.map +1 -1
  33. package/dist/mcp/tools/sf-gql-connect.js +3 -7
  34. package/dist/mcp/tools/sf-gql-connect.js.map +1 -1
  35. package/dist/mcp/tools/sf-gql-create.js +2 -6
  36. package/dist/mcp/tools/sf-gql-create.js.map +1 -1
  37. package/dist/mcp/tools/sf-gql-delete.js +2 -6
  38. package/dist/mcp/tools/sf-gql-delete.js.map +1 -1
  39. package/dist/mcp/tools/sf-gql-detail.js +2 -6
  40. package/dist/mcp/tools/sf-gql-detail.js.map +1 -1
  41. package/dist/mcp/tools/sf-gql-discover.js +2 -6
  42. package/dist/mcp/tools/sf-gql-discover.js.map +1 -1
  43. package/dist/mcp/tools/sf-gql-list.js +2 -6
  44. package/dist/mcp/tools/sf-gql-list.js.map +1 -1
  45. package/dist/mcp/tools/sf-gql-raw.js +2 -6
  46. package/dist/mcp/tools/sf-gql-raw.js.map +1 -1
  47. package/dist/mcp/tools/sf-gql-update.js +2 -6
  48. package/dist/mcp/tools/sf-gql-update.js.map +1 -1
  49. package/dist/schemas/tool-adapter.d.ts +56 -0
  50. package/dist/schemas/tool-adapter.js +129 -0
  51. package/dist/schemas/tool-adapter.js.map +1 -0
  52. package/package.json +1 -1
  53. package/src/commands/args.ts +23 -2
  54. package/src/intent/__tests__/build-aggregate.spec.ts +64 -0
  55. package/src/intent/__tests__/build-list.spec.ts +115 -2
  56. package/src/intent/build-aggregate.ts +7 -3
  57. package/src/intent/build-detail.ts +4 -2
  58. package/src/intent/build-list.ts +13 -8
  59. package/src/intent/select-child-relationship.ts +3 -2
  60. package/src/lib/__tests__/apply-command.spec.ts +17 -1
  61. package/src/lib/__tests__/query-builder.spec.ts +68 -0
  62. package/src/lib/__tests__/session.spec.ts +44 -0
  63. package/src/lib/__tests__/variable-promotion.spec.ts +58 -0
  64. package/src/lib/auth.ts +4 -2
  65. package/src/lib/errors.ts +45 -0
  66. package/src/lib/introspect.ts +6 -3
  67. package/src/lib/prime-schema.ts +12 -4
  68. package/src/lib/query-builder.ts +19 -6
  69. package/src/lib/session.ts +20 -3
  70. package/src/lib/variable-promotion.ts +9 -3
  71. package/src/lib/walker.ts +8 -6
  72. package/src/mcp/tools/__tests__/error-surface.contract.spec.ts +261 -0
  73. package/src/mcp/tools/sf-gql-aggregate.ts +2 -6
  74. package/src/mcp/tools/sf-gql-connect.ts +3 -7
  75. package/src/mcp/tools/sf-gql-create.ts +2 -6
  76. package/src/mcp/tools/sf-gql-delete.ts +2 -6
  77. package/src/mcp/tools/sf-gql-detail.ts +2 -6
  78. package/src/mcp/tools/sf-gql-discover.ts +2 -6
  79. package/src/mcp/tools/sf-gql-list.ts +2 -6
  80. package/src/mcp/tools/sf-gql-raw.ts +2 -6
  81. package/src/mcp/tools/sf-gql-update.ts +2 -6
  82. package/src/schemas/__tests__/tool-adapter.spec.ts +299 -0
  83. package/src/schemas/tool-adapter.ts +165 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ import os from "node:os";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { AuthError, SchemaError, UserInputError } from "../../lib/errors.js";
10
+ import { SchemaRefreshError } from "../../lib/prime-schema.js";
11
+ import { MutationContextError } from "../../lib/walker.js";
12
+ import { classifyError, PATH_MARKERS, runTool } from "../tool-adapter.js";
13
+
14
+ describe("schemas/tool-adapter — classifyError", () => {
15
+ describe("typed-marker routing (primary signal)", () => {
16
+ it("routes SchemaError to Schema", () => {
17
+ const r = classifyError(new SchemaError("No cached schema for org"));
18
+ expect(r.category).toBe("Schema");
19
+ expect(r.text).toBe("No cached schema for org");
20
+ });
21
+
22
+ it("routes SchemaRefreshError to Schema", () => {
23
+ const r = classifyError(
24
+ new SchemaRefreshError("refresh failed; keeping cache", {
25
+ instanceUrl: "https://x.my.salesforce.com",
26
+ }),
27
+ );
28
+ expect(r.category).toBe("Schema");
29
+ expect(r.text).toBe("refresh failed; keeping cache");
30
+ });
31
+
32
+ it("routes AuthError to Auth", () => {
33
+ const r = classifyError(new AuthError('Failed to get org info for "foo"'));
34
+ expect(r.category).toBe("Auth");
35
+ expect(r.text).toBe('Failed to get org info for "foo"');
36
+ });
37
+
38
+ it("routes MutationContextError to UserInput", () => {
39
+ const r = classifyError(
40
+ new MutationContextError("field X is not available in mutation results"),
41
+ );
42
+ expect(r.category).toBe("UserInput");
43
+ expect(r.text).toBe("field X is not available in mutation results");
44
+ });
45
+
46
+ // A typed marker wins even when its message would heuristically match a
47
+ // different category — e.g. a SchemaError whose text mentions a builder.
48
+ it("prefers the typed marker over a conflicting heuristic", () => {
49
+ const r = classifyError(new SchemaError("buildList: is not a valid GraphQL Name"));
50
+ expect(r.category).toBe("Schema");
51
+ });
52
+ });
53
+
54
+ describe("heuristic routing (untyped fallbacks)", () => {
55
+ it("classifies builder/GraphQL-Name failures as UserInput", () => {
56
+ expect(
57
+ classifyError(new Error("buildList: object 'Order Item' is not a valid GraphQL Name"))
58
+ .category,
59
+ ).toBe("UserInput");
60
+ expect(
61
+ classifyError(new Error("buildAggregate: aggregation 'sum' requires a field (FR-8.3)"))
62
+ .category,
63
+ ).toBe("UserInput");
64
+ expect(classifyError(new Error('Field "Bogus" not found on "Account".')).category).toBe(
65
+ "UserInput",
66
+ );
67
+ });
68
+
69
+ it("classifies untyped auth-shaped messages as Auth", () => {
70
+ expect(
71
+ classifyError(new Error('Missing accessToken or instanceUrl for "foo"')).category,
72
+ ).toBe("Auth");
73
+ expect(classifyError(new Error("run `sf org login` first")).category).toBe("Auth");
74
+ });
75
+
76
+ it("classifies untyped schema-shaped messages as Schema", () => {
77
+ expect(classifyError(new Error("No cached schema for org foo")).category).toBe("Schema");
78
+ expect(classifyError(new Error("Introspection query returned errors")).category).toBe(
79
+ "Schema",
80
+ );
81
+ });
82
+
83
+ it("falls back to Internal for anything unrecognized", () => {
84
+ expect(classifyError(new Error("kaboom")).category).toBe("Internal");
85
+ });
86
+
87
+ it("handles a non-Error throw via String()", () => {
88
+ const r = classifyError("a bare string");
89
+ expect(r.category).toBe("Internal");
90
+ expect(r.text).toContain("a bare string");
91
+ });
92
+ });
93
+
94
+ describe("Internal sanitization (info-disclosure guard)", () => {
95
+ it("strips home dir + absolute repo paths and truncates the stack", () => {
96
+ const home = os.homedir();
97
+ const err = new Error(`boom at ${home}/secret/file.ts`);
98
+ err.stack = [
99
+ `Error: boom at ${home}/secret/file.ts`,
100
+ ` at fn (${home}/Development/webapps/packages/graphiti/src/lib/foo.ts:10:5)`,
101
+ ` at bar (/some/abs/node_modules/pkg/index.js:1:1)`,
102
+ ` at baz (${home}/x/y.ts:2:2)`,
103
+ ` at dropped (${home}/should/not/appear.ts:9:9)`,
104
+ ].join("\n");
105
+
106
+ const r = classifyError(err);
107
+ expect(r.category).toBe("Internal");
108
+
109
+ // No local filesystem layout leaks to the host.
110
+ expect(r.text).not.toContain(home);
111
+ // Repo-absolute prefix is relativized to a package-relative path.
112
+ expect(r.text).toContain("packages/graphiti/src/lib/foo.ts");
113
+ expect(r.text).toContain("node_modules/pkg/index.js");
114
+ // Home-rooted frames keep a tilde marker instead of the absolute path.
115
+ expect(r.text).toContain("~/x/y.ts");
116
+ // Message line is sanitized too.
117
+ expect(r.text).toContain("boom at ~/secret/file.ts");
118
+ // Stack is truncated: the 4th-and-beyond frames are dropped.
119
+ expect(r.text).not.toContain("dropped");
120
+ expect(r.text.split("\n")).toHaveLength(4); // message + 3 frames
121
+ });
122
+
123
+ // N4 fix: assert relativization against a foreign (non-HOME) absolute path,
124
+ // so the test can't pass tautologically off the same os.homedir() the
125
+ // adapter captured.
126
+ it("relativizes a repo path under a foreign home, not just the test HOME", () => {
127
+ const err = new Error("boom");
128
+ err.stack = [
129
+ "Error: boom",
130
+ " at fn (/home/someoneelse/ci/checkout/packages/graphiti/src/x.ts:1:1)",
131
+ ].join("\n");
132
+ const r = classifyError(err);
133
+ expect(r.text).toContain("packages/graphiti/src/x.ts");
134
+ expect(r.text).not.toContain("/home/someoneelse");
135
+ });
136
+ });
137
+
138
+ describe("typed UserInput + heuristic anchoring (M3/M5)", () => {
139
+ it("routes UserInputError to UserInput (M3 — walker navigation is now typed)", () => {
140
+ expect(
141
+ classifyError(
142
+ new UserInputError('Type "X" is not a possible type of union Y. Allowed: A, B'),
143
+ ).category,
144
+ ).toBe("UserInput");
145
+ expect(
146
+ classifyError(new UserInputError('Type "X" does not implement interface Y. Allowed: A'))
147
+ .category,
148
+ ).toBe("UserInput");
149
+ });
150
+
151
+ // M5: messages an earlier, looser heuristic would have mislabeled UserInput
152
+ // must stay Internal — otherwise a genuine internal bug is both miscategorized
153
+ // AND silently dropped from the stderr operator log.
154
+ it("keeps internal-shaped messages as Internal (no regex false-positives)", () => {
155
+ expect(classifyError(new Error('The "path" argument requires a string')).category).toBe(
156
+ "Internal",
157
+ );
158
+ expect(classifyError(new Error("Module not found on disk")).category).toBe("Internal");
159
+ expect(classifyError(new Error("Config must contain at least one entry")).category).toBe(
160
+ "Internal",
161
+ );
162
+ expect(classifyError(new Error("the usage is undocumented")).category).toBe("Internal");
163
+ expect(classifyError(new Error("Cannot index into the cache map")).category).toBe("Internal");
164
+ });
165
+
166
+ it("still classifies the real anchored UserInput shapes correctly (no false-negatives)", () => {
167
+ const userInput = [
168
+ "select requires at least one <path[:alias]>",
169
+ "var requires a name, e.g. var $id <path> [default]",
170
+ "set: usage is `set [<field-path>] <key>=<value> ...` or `set <path> <value>`",
171
+ 'Field "Bogus" not found on type Account. Available: Id, Name',
172
+ 'Field "x" not found on input type AccountInput. Available: Name',
173
+ 'Cannot index into non-list type Account with "0".',
174
+ 'sf_gql_discover mode "describe_field" requires "field".',
175
+ "buildAggregate: aggregation 'sum' requires a field (FR-8.3)",
176
+ ];
177
+ for (const msg of userInput) {
178
+ expect(classifyError(new Error(msg)).category).toBe("UserInput");
179
+ }
180
+ });
181
+ });
182
+ });
183
+
184
+ describe("schemas/tool-adapter — path sanitization across all categories (M1/M6/H1)", () => {
185
+ const savedHome = process.env.GRAPHITI_HOME;
186
+ afterEach(() => {
187
+ if (savedHome === undefined) delete process.env.GRAPHITI_HOME;
188
+ else process.env.GRAPHITI_HOME = savedHome;
189
+ });
190
+
191
+ it("sanitizes a Schema error's embedded cache/lock path — verbatim categories are NOT exempt (M1)", () => {
192
+ // GRAPHITI_HOME outside the home dir (CI/shared mount) — the old home-only
193
+ // strip would have missed this; relativizing against schemaDir() catches it (H1).
194
+ process.env.GRAPHITI_HOME = "/srv/shared/graphiti";
195
+ const lock = "/srv/shared/graphiti/schemas/deadbeef.json.lock";
196
+ const r = classifyError(
197
+ new SchemaError(`Timed out waiting 420000ms for schema priming lock at ${lock}`),
198
+ );
199
+ expect(r.category).toBe("Schema");
200
+ expect(r.text).not.toContain("/srv/shared/graphiti");
201
+ expect(r.text).toContain(PATH_MARKERS.schemaCache);
202
+ });
203
+
204
+ it("sanitizes a home-rooted path embedded in an Auth error message (M1)", () => {
205
+ const home = os.homedir();
206
+ const r = classifyError(new AuthError(`Failed to get org info; see ${home}/.sfdx/alias.json`));
207
+ expect(r.category).toBe("Auth");
208
+ expect(r.text).not.toContain(home);
209
+ expect(r.text).toContain(`${PATH_MARKERS.home}/.sfdx/alias.json`);
210
+ });
211
+
212
+ it("redacts an unknown absolute path (/tmp, /var/folders) anywhere in the message (H1)", () => {
213
+ const r = classifyError(
214
+ new Error("kaboom while reading /var/folders/ab/cd/T/secret.json mid-flight"),
215
+ );
216
+ expect(r.category).toBe("Internal");
217
+ expect(r.text).not.toContain("/var/folders/ab/cd/T/secret.json");
218
+ expect(r.text).toContain(PATH_MARKERS.redacted);
219
+ });
220
+
221
+ it("does not mangle URLs (their path is not a filesystem path)", () => {
222
+ const r = classifyError(
223
+ new Error("posted to https://acme.my.salesforce.com/services/data/v59.0 and failed"),
224
+ );
225
+ expect(r.text).toContain("https://acme.my.salesforce.com/services/data/v59.0");
226
+ });
227
+
228
+ // Ciaran's blocker: U+2028/U+2029 are not escaped by JSON.stringify and trip a
229
+ // Claude.AI 408 (MCP TS SDK #2155). They must be stripped from the error text.
230
+ it("strips U+2028/U+2029 line separators from a classified error message", () => {
231
+ const r = classifyError(new SchemaError("No cached schema\u2028for org\u2029x"));
232
+ expect(r.category).toBe("Schema");
233
+ expect(r.text).not.toMatch(/[\u2028\u2029]/);
234
+ expect(r.text).toBe("No cached schemafor orgx");
235
+ });
236
+ });
237
+
238
+ describe("schemas/tool-adapter — runTool", () => {
239
+ afterEach(() => {
240
+ vi.restoreAllMocks();
241
+ });
242
+
243
+ it("returns a plain JSON text envelope on success (no isError)", async () => {
244
+ const result = await runTool(async () => ({ ok: true, n: 1 }));
245
+ expect(result.isError).toBeUndefined();
246
+ expect(result.content).toHaveLength(1);
247
+ expect(result.content[0]).toEqual({ type: "text", text: JSON.stringify({ ok: true, n: 1 }) });
248
+ });
249
+
250
+ // Ciaran's note: the SUCCESS envelope also serializes user/codegen output, which
251
+ // JSON.stringify won't escape — so runTool must strip U+2028/2029 there too.
252
+ it("strips U+2028/U+2029 from the success envelope (codegen output vector)", async () => {
253
+ const result = await runTool(async () => ({ types: "type X = {\u2028 a: string \u2029}" }));
254
+ expect(result.isError).toBeUndefined();
255
+ expect(result.content[0]?.text).not.toMatch(/[\u2028\u2029]/);
256
+ expect(result.content[0]?.text).toBe(JSON.stringify({ types: "type X = { a: string }" }));
257
+ });
258
+
259
+ it("prefixes each category in the isError envelope", async () => {
260
+ const cases: { throw: unknown; prefix: string }[] = [
261
+ { throw: new MutationContextError("bad mutation field"), prefix: "UserInput: " },
262
+ { throw: new AuthError("Failed to get org info"), prefix: "Auth: " },
263
+ { throw: new SchemaError("No cached schema"), prefix: "Schema: " },
264
+ { throw: new Error("kaboom"), prefix: "Internal: " },
265
+ ];
266
+ for (const c of cases) {
267
+ const result = await runTool(async () => {
268
+ throw c.throw;
269
+ });
270
+ expect(result.isError).toBe(true);
271
+ expect(result.content[0]?.type).toBe("text");
272
+ expect(result.content[0]?.text.startsWith(c.prefix)).toBe(true);
273
+ }
274
+ });
275
+
276
+ it("logs the full Internal error to stderr but returns only the sanitized envelope", async () => {
277
+ const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
278
+ const home = os.homedir();
279
+ const err = new Error(`internal boom at ${home}/private/x.ts`);
280
+ const result = await runTool(async () => {
281
+ throw err;
282
+ });
283
+
284
+ // Full error (with the real path) is logged off the stdio JSON-RPC channel.
285
+ expect(spy).toHaveBeenCalledWith("[graphiti-mcp] Internal tool error:", err);
286
+ // The returned envelope is sanitized.
287
+ expect(result.isError).toBe(true);
288
+ expect(result.content[0]?.text.startsWith("Internal: ")).toBe(true);
289
+ expect(result.content[0]?.text).not.toContain(home);
290
+ });
291
+
292
+ it("does not log non-Internal (expected) errors to stderr", async () => {
293
+ const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
294
+ await runTool(async () => {
295
+ throw new AuthError("Failed to get org info");
296
+ });
297
+ expect(spy).not.toHaveBeenCalled();
298
+ });
299
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ import os from "node:os";
8
+ import { AuthError, SchemaError, UserInputError } from "../lib/errors.js";
9
+ import { graphitiHome, schemaDir } from "../lib/introspect.js";
10
+ import { SchemaRefreshError } from "../lib/prime-schema.js";
11
+ import { MutationContextError } from "../lib/walker.js";
12
+
13
+ /**
14
+ * Shared MCP tool adapter (W-22697673). Wraps an intent invocation so that any
15
+ * throw becomes a sanitized, category-prefixed error envelope instead of leaking
16
+ * a raw stack/file path to the MCP host. Categories (message prefix):
17
+ * - `UserInput:` — bad agent input / spec violations (FR-8.3/8.4, GraphQL Name,
18
+ * walker navigation).
19
+ * - `Auth:` — credential resolution failures.
20
+ * - `Schema:` — introspection / priming / schema-build failures.
21
+ * - `Internal:` — everything else; message + a truncated stack (the full error
22
+ * is logged to stderr, off the stdio channel).
23
+ *
24
+ * Auth/Schema/UserInput are carried by typed markers (AuthError, SchemaError,
25
+ * SchemaRefreshError, UserInputError, MutationContextError) classified by
26
+ * `instanceof`; the message-shape heuristics below are a secondary fallback for
27
+ * untyped throws. The sanitized message is returned for EVERY category — not
28
+ * just Internal — because typed Auth/Schema errors embed cache/lock paths and
29
+ * wrapped jsforce/@salesforce/core causes embed `~/.sfdx/...` paths.
30
+ */
31
+
32
+ export type ErrorCategory = "UserInput" | "Auth" | "Schema" | "Internal";
33
+
34
+ interface ToolTextResult {
35
+ // Index signature mirrors the MCP SDK's CallToolResult so this is assignable
36
+ // to a registerTool handler's return type.
37
+ [key: string]: unknown;
38
+ content: { type: "text"; text: string }[];
39
+ isError?: boolean;
40
+ }
41
+
42
+ // UserInput failures thrown by the intent/lib layer that aren't already carried
43
+ // by a typed error. Each alternative is anchored to (or contextualized by) the
44
+ // actual emitting message so a generic Node/library error does NOT get
45
+ // mislabeled UserInput (which would also hide it from the Internal stderr log).
46
+ // New UserInput sites should prefer throwing `UserInputError` over extending this.
47
+ const USER_INPUT_RE =
48
+ /(^build[A-Z]\w*:|^sf_gql_discover |^command \d+ \(|^empty command\b|^unknown command\b|^(?:select|set|var)\b.*\brequires\b|^set: usage is|is not a valid GraphQL Name|not found on (?:type|input type|field|any member of union)|not found on "|not found in schema|Cannot select field|Cannot apply an inline fragment|Cannot navigate into|Cannot index into non-list type|^Argument "|Empty field name|not supported in v1|Invalid org alias or username)/;
49
+
50
+ // Defensive fallbacks in case a schema/auth failure ever reaches the adapter
51
+ // untyped (the typed AuthError/SchemaError markers are the primary signal).
52
+ const SCHEMA_RE =
53
+ /(No cached schema|Introspection query|Schema has no |did not return a __schema|Schema priming)/;
54
+ const AUTH_RE = /(Failed to get org info|Missing accessToken or instanceUrl|sf org login)/;
55
+
56
+ /**
57
+ * Markers substituted for redacted local paths. Exported as the single source of
58
+ * truth so tests assert against these constants instead of re-typing the literals
59
+ * (closes the drift between the sanitizer and its tests).
60
+ */
61
+ export const PATH_MARKERS = {
62
+ schemaCache: "<schema-cache>",
63
+ graphitiHome: "<graphiti-home>",
64
+ home: "~",
65
+ redacted: "<path>",
66
+ } as const;
67
+
68
+ // Unicode line separators (U+2028/U+2029) are legal in JSON strings but are NOT
69
+ // escaped by JSON.stringify; left raw in `content.text` they trip a Claude.AI 408
70
+ // timeout (MCP TS SDK #2155). Strip them from every host-visible envelope.
71
+ const LINE_SEPARATOR_RE = /[\u2028\u2029]/g;
72
+ function stripLineSeparators(s: string): string {
73
+ return s.replace(LINE_SEPARATOR_RE, "");
74
+ }
75
+
76
+ /**
77
+ * Redact local filesystem layout from error text so the MCP host never sees the
78
+ * developer's home dir, OS username, repo checkout location, or schema-cache
79
+ * path (W-22697673 info-disclosure). Applied to every category's message.
80
+ */
81
+ function sanitizePaths(s: string): string {
82
+ let out = stripLineSeparators(s).replace(/file:\/\//g, "");
83
+ // 1) Repo-internal: drop any absolute prefix before packages/ or node_modules/
84
+ // so a frame relativizes to `packages/graphiti/...` regardless of checkout.
85
+ out = out.replace(/(?:\/[^\s/]+)+\/(?=packages\/|node_modules\/)/g, "");
86
+ // 2) Relativize known graphiti roots to stable, non-sensitive markers. Longest
87
+ // first: schemaDir() is under graphitiHome() is (usually) under homedir().
88
+ // Relativizing against schemaDir()/graphitiHome() — not just homedir() —
89
+ // closes the leak when GRAPHITI_HOME lives outside the home dir (CI/shared mounts).
90
+ const roots: [string, string][] = (
91
+ [
92
+ [schemaDir(), PATH_MARKERS.schemaCache],
93
+ [graphitiHome(), PATH_MARKERS.graphitiHome],
94
+ [os.homedir(), PATH_MARKERS.home],
95
+ ] as [string, string][]
96
+ )
97
+ .filter(([root]) => root.length > 1)
98
+ .sort((a, b) => b[0].length - a[0].length);
99
+ for (const [root, marker] of roots) out = out.split(root).join(marker);
100
+ // 3) Redact any remaining absolute path (POSIX or Windows) at a token boundary
101
+ // — /tmp, /var/folders, a foreign user's home, etc. The leading-boundary
102
+ // guard avoids mangling URLs, whose path follows a non-boundary char (":"/host).
103
+ out = out.replace(
104
+ /(^|[\s("'=])(?:[A-Za-z]:)?(?:[/\\][^\s:)"',\\]+){2,}/g,
105
+ (_m, pre: string) => `${pre}${PATH_MARKERS.redacted}`,
106
+ );
107
+ return out;
108
+ }
109
+
110
+ function truncatedStack(e: unknown): string[] {
111
+ if (!(e instanceof Error) || !e.stack) return [];
112
+ return e.stack
113
+ .split("\n")
114
+ .slice(1, 4) // first ~3 frames after the message line
115
+ .map((line) => sanitizePaths(line.trim()));
116
+ }
117
+
118
+ function categoryOf(e: unknown, message: string): ErrorCategory {
119
+ if (e instanceof SchemaRefreshError || e instanceof SchemaError) return "Schema";
120
+ if (e instanceof AuthError) return "Auth";
121
+ if (e instanceof UserInputError || e instanceof MutationContextError) return "UserInput";
122
+ if (USER_INPUT_RE.test(message)) return "UserInput";
123
+ if (AUTH_RE.test(message)) return "Auth";
124
+ if (SCHEMA_RE.test(message)) return "Schema";
125
+ return "Internal";
126
+ }
127
+
128
+ /** Classify a thrown error into a category + sanitized message text. */
129
+ export function classifyError(e: unknown): { category: ErrorCategory; text: string } {
130
+ const message = e instanceof Error ? e.message : String(e);
131
+ const category = categoryOf(e, message);
132
+ const safeMessage = sanitizePaths(message);
133
+ if (category !== "Internal") return { category, text: safeMessage };
134
+
135
+ // Internal: unexpected. Attach a truncated, path-stripped stack for the host.
136
+ const frames = truncatedStack(e);
137
+ const text = frames.length
138
+ ? `${safeMessage}\n${frames.map((f) => ` ${f}`).join("\n")}`
139
+ : safeMessage;
140
+ return { category, text };
141
+ }
142
+
143
+ /**
144
+ * Run an MCP tool's intent invocation. Returns the success envelope
145
+ * (`JSON.stringify(output)`) or a category-prefixed `{ isError: true }` envelope.
146
+ * Internal (unexpected) errors are additionally logged in full to stderr, which
147
+ * is separate from the stdio JSON-RPC channel, so operators keep the real stack.
148
+ */
149
+ export async function runTool(fn: () => Promise<unknown>): Promise<ToolTextResult> {
150
+ try {
151
+ const output = await fn();
152
+ // Strip U+2028/2029 from the success envelope too: codegen/GraphQL output can
153
+ // carry them and JSON.stringify won't escape them (MCP TS SDK #2155).
154
+ return { content: [{ type: "text", text: stripLineSeparators(JSON.stringify(output)) }] };
155
+ } catch (e) {
156
+ const { category, text } = classifyError(e);
157
+ if (category === "Internal") {
158
+ console.error("[graphiti-mcp] Internal tool error:", e);
159
+ }
160
+ return {
161
+ isError: true,
162
+ content: [{ type: "text", text: stripLineSeparators(`${category}: ${text}`) }],
163
+ };
164
+ }
165
+ }