@salesforce/graphiti 10.10.3 → 10.11.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 (44) hide show
  1. package/AGENT_GUIDE.md +23 -0
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +17 -6
  4. package/dist/commands/connect.d.ts +2 -1
  5. package/dist/commands/connect.js +28 -7
  6. package/dist/commands/connect.js.map +1 -1
  7. package/dist/intent/build-connect.d.ts +24 -0
  8. package/dist/intent/build-connect.js +47 -0
  9. package/dist/intent/build-connect.js.map +1 -0
  10. package/dist/intent/types.d.ts +23 -0
  11. package/dist/lib/fs-utils.d.ts +3 -1
  12. package/dist/lib/fs-utils.js +7 -3
  13. package/dist/lib/fs-utils.js.map +1 -1
  14. package/dist/lib/introspect.js +20 -1
  15. package/dist/lib/introspect.js.map +1 -1
  16. package/dist/lib/prime-schema.d.ts +70 -16
  17. package/dist/lib/prime-schema.js +166 -33
  18. package/dist/lib/prime-schema.js.map +1 -1
  19. package/dist/lib/walker.d.ts +10 -0
  20. package/dist/lib/walker.js +26 -2
  21. package/dist/lib/walker.js.map +1 -1
  22. package/dist/mcp/server.js +2 -0
  23. package/dist/mcp/server.js.map +1 -1
  24. package/dist/mcp/tools/sf-gql-connect.d.ts +9 -0
  25. package/dist/mcp/tools/sf-gql-connect.js +27 -0
  26. package/dist/mcp/tools/sf-gql-connect.js.map +1 -0
  27. package/package.json +1 -1
  28. package/src/__tests__/helpers/object-info.ts +37 -0
  29. package/src/commands/__tests__/connect.spec.ts +92 -0
  30. package/src/commands/connect.ts +28 -6
  31. package/src/intent/__tests__/build-connect.spec.ts +103 -0
  32. package/src/intent/build-connect.ts +55 -0
  33. package/src/intent/types.ts +25 -0
  34. package/src/lib/__tests__/introspect.spec.ts +34 -0
  35. package/src/lib/__tests__/prime-schema.spec.ts +341 -0
  36. package/src/lib/__tests__/walker.spec.ts +13 -0
  37. package/src/lib/fs-utils.ts +8 -3
  38. package/src/lib/introspect.ts +29 -6
  39. package/src/lib/prime-schema.ts +184 -32
  40. package/src/lib/walker.ts +26 -2
  41. package/src/mcp/__tests__/server.spec.ts +1 -0
  42. package/src/mcp/server.ts +2 -0
  43. package/src/mcp/tools/__tests__/sf-gql-connect.spec.ts +202 -0
  44. package/src/mcp/tools/sf-gql-connect.ts +42 -0
@@ -9,11 +9,14 @@ import path from "node:path";
9
9
  import { getOrgAuth as realGetOrgAuth, type OrgAuth } from "./auth.js";
10
10
  import {
11
11
  downloadSchema as realDownloadSchema,
12
+ getSchemaMetadata,
12
13
  normalizeInstanceUrl,
13
14
  schemaCacheKeyForInstanceUrl,
14
15
  schemaDir,
15
16
  type SchemaMetadata,
16
17
  } from "./introspect.js";
18
+ import { clearObjectInfoCache } from "./object-info.js";
19
+ import { clearSchemaCacheByUrl } from "./walker.js";
17
20
 
18
21
  // FR-13.7 originally suggested a 10-30s introspection budget. Empirical
19
22
  // smoke testing against real Salesforce UIAPI schemas measures ~135s for
@@ -33,23 +36,29 @@ const MAX_WAIT_MS = 7 * 60_000;
33
36
  * since `mkdir` is atomic on POSIX), run `work`, then release the lock.
34
37
  *
35
38
  * If another process holds the lock, this polls every 100ms for the lock
36
- * to be released. On release, if `finalPath` now exists, the work function
37
- * is skipped (the other process already did it). If `finalPath` still does
38
- * not exist (the other process aborted without writing), this acquires the
39
- * lock and runs `work`.
39
+ * to be released. On release, if the work is already satisfied (per `skipIf`),
40
+ * the work function is skipped (the other process already did it). Otherwise
41
+ * this acquires the lock and runs `work`.
40
42
  *
41
- * Contract: `work` will NOT run when `finalPath` exists at any point during
43
+ * Contract: `work` will NOT run when `skipIf()` is true at any point during
42
44
  * the lock dance — including immediately after we acquire the lock (a holder
43
45
  * may have finished and released the lock between our last EEXIST poll and
44
46
  * our successful `mkdir`). In that case the lock is released and the
45
47
  * function returns `undefined`.
46
48
  *
49
+ * `skipIf` defaults to "`finalPath` exists" — the lazy-prime contract (any
50
+ * cached schema is good enough). A forced refresh passes a predicate that is
51
+ * true only once the file has been *replaced* since the refresh was requested,
52
+ * which coalesces concurrent refreshes onto a single introspection.
53
+ *
47
54
  * Stale locks (older than STALE_LOCK_MS) are reclaimed.
48
55
  */
49
56
  export async function withSchemaLock<T>(
50
57
  finalPath: string,
51
58
  work: () => Promise<T>,
59
+ opts: { skipIf?: () => boolean } = {},
52
60
  ): Promise<T | undefined> {
61
+ const skip = opts.skipIf ?? (() => fs.existsSync(finalPath));
53
62
  const lockPath = `${finalPath}.lock`;
54
63
  fs.mkdirSync(path.dirname(finalPath), { recursive: true });
55
64
 
@@ -62,8 +71,8 @@ export async function withSchemaLock<T>(
62
71
  const err = e as NodeJS.ErrnoException;
63
72
  if (err.code !== "EEXIST") throw e;
64
73
 
65
- // If the cache exists now, another holder finished — short-circuit.
66
- if (fs.existsSync(finalPath)) return undefined;
74
+ // If the work is already satisfied, another holder finished — short-circuit.
75
+ if (skip()) return undefined;
67
76
 
68
77
  // Stale-lock reclaim. Known TOCTOU: between this `statSync` and
69
78
  // the `rmSync` below, another process may have already reclaimed
@@ -94,10 +103,10 @@ export async function withSchemaLock<T>(
94
103
  }
95
104
  }
96
105
 
97
- // We now hold the lock. Re-check for the cache: a holder that finished
98
- // between our last EEXIST poll and our successful mkdir may have just
99
- // primed it. Avoid running `work` redundantly.
100
- if (fs.existsSync(finalPath)) {
106
+ // We now hold the lock. Re-check `skip`: a holder that finished between our
107
+ // last EEXIST poll and our successful mkdir may have just satisfied it.
108
+ // Avoid running `work` redundantly.
109
+ if (skip()) {
101
110
  try {
102
111
  fs.rmSync(lockPath, { recursive: true });
103
112
  } catch {
@@ -122,10 +131,61 @@ export async function withSchemaLock<T>(
122
131
 
123
132
  export interface PrimeResult {
124
133
  cached: boolean;
134
+ /** True only when this call performed a forced re-download (forceRefresh). */
135
+ refreshed: boolean;
125
136
  filePath: string;
126
137
  durationMs: number;
127
138
  /** Resolved instance URL (already auth-resolved). */
128
139
  instanceUrl: string;
140
+ /**
141
+ * Non-`__` type count from the just-downloaded schema. Present only when this
142
+ * call performed a download (`cached: false`); a cache hit or coalesced
143
+ * refresh leaves it undefined to avoid re-parsing the (large) introspection
144
+ * JSON just to count types.
145
+ */
146
+ typeCount?: number;
147
+ }
148
+
149
+ /**
150
+ * Thrown when a forced refresh fails to download a new schema. The previously
151
+ * cached schema (if any) is left intact on disk and in memory — we only clear
152
+ * the caches *after* a successful download. `staleSince` is the surviving
153
+ * cache's download timestamp (ISO) so callers can render a staleness-aware
154
+ * message; it is undefined when no prior cache existed (a true hard failure) —
155
+ * or when a cache file is present but unparseable, since `getSchemaMetadata`
156
+ * returns null for a corrupt file, which correctly degrades to a hard failure
157
+ * (a corrupt cache is not usable). `instanceUrl` is always the resolved org URL
158
+ * (known before the download).
159
+ */
160
+ export class SchemaRefreshError extends Error {
161
+ staleSince?: string;
162
+ instanceUrl: string;
163
+ constructor(
164
+ message: string,
165
+ opts: { instanceUrl: string; staleSince?: string; cause?: unknown },
166
+ ) {
167
+ super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
168
+ this.name = "SchemaRefreshError";
169
+ this.staleSince = opts.staleSince;
170
+ this.instanceUrl = opts.instanceUrl;
171
+ }
172
+ }
173
+
174
+ function formatAge(ms: number): string {
175
+ const min = Math.floor(ms / 60_000);
176
+ if (min < 1) return "just now";
177
+ if (min < 60) return `${min}m ago`;
178
+ const hr = Math.floor(min / 60);
179
+ if (hr < 24) return `${hr}h ago`;
180
+ return `${Math.floor(hr / 24)}d ago`;
181
+ }
182
+
183
+ function buildStaleMessage(orgAlias: string, surviving: SchemaMetadata | null): string {
184
+ if (!surviving) {
185
+ return `Schema refresh for "${orgAlias}" failed and no cached schema exists yet. Resolve connectivity/auth and run \`graphiti connect ${orgAlias}\` again.`;
186
+ }
187
+ const age = formatAge(Date.now() - new Date(surviving.downloadedAt).getTime());
188
+ return `Schema refresh for "${orgAlias}" failed; keeping the previously cached schema (downloaded ${age}). The cached schema is still usable — try refreshing again shortly.`;
129
189
  }
130
190
 
131
191
  /**
@@ -143,25 +203,43 @@ const REAL_DEPS: PrimeDeps = {
143
203
  };
144
204
 
145
205
  /**
146
- * Lazily prime the on-disk schema cache for `orgAlias`.
206
+ * Lazily prime — or, with `forceRefresh`, re-download — the on-disk schema
207
+ * cache for `orgAlias`.
147
208
  *
209
+ * Lazy prime (`forceRefresh` falsy):
148
210
  * - If `<HOME>/.graphiti/schemas/<hash>.json` already exists, returns
149
- * `{cached: true}` immediately.
211
+ * `{cached: true, refreshed: false}` immediately.
150
212
  * - Otherwise: resolves auth via `deps.getOrgAuth` (throws verbatim if the
151
213
  * alias is not authenticated, per FR-13.5), acquires the schema lock via
152
214
  * `withSchemaLock`, calls `deps.downloadSchema(auth)` (which writes the
153
- * cache atomically via `downloadSchema`'s internal use of
154
- * `atomicWriteJson`), releases the lock, and returns
155
- * `{cached: false, durationMs}`. Per FR-13.6, partial caches never reach
156
- * disk because writes are atomic.
215
+ * cache atomically), releases the lock, and returns `{cached: false}`. Per
216
+ * FR-13.6, partial caches never reach disk because writes are atomic.
217
+ * `downloadSchema` bounds the request with a timeout and retries transient
218
+ * failures (network / 5xx) once at the connection layer; deterministic
219
+ * failures (4xx, GraphQL errors in a 200 body, missing `__schema`) and missing
220
+ * auth surface immediately. Unlike a forced refresh, a lazy-prime failure is
221
+ * re-thrown verbatim (there is no prior cache to keep, so no `SchemaRefreshError`).
222
+ *
223
+ * Forced refresh (`forceRefresh: true`) — W-22845606:
224
+ * - Re-downloads even though the file exists, then clears all three caches
225
+ * coherently: the on-disk introspection JSON (overwritten atomically), this
226
+ * process's in-memory parsed-schema cache, and the ObjectInfo cache (memory
227
+ * + disk). Clearing happens only AFTER a successful download, so a failed
228
+ * refresh leaves every cache intact.
229
+ * - Coalesces concurrent refreshes (CLI + MCP) into a single introspection via
230
+ * a request-time mtime snapshot: a peer that replaced the file since this
231
+ * refresh was requested satisfies us, and we skip the redundant download.
232
+ * - The download retries transient failures (network / 5xx) once at the
233
+ * connection layer. On terminal failure throws {@link SchemaRefreshError}
234
+ * carrying the surviving cache's age; the old caches are kept.
157
235
  *
158
- * Concurrent callers in the same or different processes serialize on the
159
- * lock dir; only one performs introspection. Subsequent waiters observe
160
- * the primed cache and short-circuit (FR-13.4).
236
+ * Concurrent callers in the same or different processes serialize on the lock
237
+ * dir; only one performs introspection.
161
238
  */
162
239
  export async function primeSchemaWithLock(
163
240
  orgAlias: string,
164
241
  deps: PrimeDeps = REAL_DEPS,
242
+ opts: { forceRefresh?: boolean } = {},
165
243
  ): Promise<PrimeResult> {
166
244
  // We must call `deps.getOrgAuth` first because it is the only injected
167
245
  // source of the org's `instanceUrl`, which the cache key is derived from.
@@ -173,23 +251,97 @@ export async function primeSchemaWithLock(
173
251
  const cacheKey = schemaCacheKeyForInstanceUrl(instanceUrl);
174
252
  const filePath = path.join(schemaDir(), `${cacheKey}.json`);
175
253
 
176
- if (fs.existsSync(filePath)) {
177
- return { cached: true, filePath, durationMs: 0, instanceUrl };
254
+ const forceRefresh = !!opts.forceRefresh;
255
+
256
+ // Snapshot the cached file's mtime before we (might) overwrite it.
257
+ // `atomicWriteJson` installs the new JSON via rename, so a successful
258
+ // download bumps mtime forward. The refresh skip-predicate compares
259
+ // against this snapshot (mtime-to-mtime, immune to wall-clock skew) so a
260
+ // peer that replaced the file since we were asked coalesces us out.
261
+ // Best-effort: on filesystems with coarse mtime resolution two refreshes in
262
+ // the same tick may each download (redundant, never incorrect — strict `>`
263
+ // can only cause an extra download, never an erroneous skip of a real refresh).
264
+ const mtimeAtRequest = fs.existsSync(filePath) ? fs.statSync(filePath).mtimeMs : -1;
265
+
266
+ if (fs.existsSync(filePath) && !forceRefresh) {
267
+ return { cached: true, refreshed: false, filePath, durationMs: 0, instanceUrl };
178
268
  }
179
269
 
180
270
  const start = Date.now();
181
271
  let primed = false;
182
- await withSchemaLock(filePath, async () => {
183
- // withSchemaLock re-checks `finalPath` after acquire and short-
184
- // circuits if it now exists, so when our work runs we know we
185
- // must do the introspection.
186
- await deps.downloadSchema(auth);
187
- primed = true;
188
- });
272
+ let typeCount: number | undefined;
273
+ await withSchemaLock(
274
+ filePath,
275
+ async () => {
276
+ let meta: SchemaMetadata;
277
+ try {
278
+ // `downloadSchema` bounds the request with a timeout and retries
279
+ // transient (network/5xx) failures once with backoff at the
280
+ // connection layer (jsforce); deterministic failures throw straight
281
+ // through.
282
+ meta = await deps.downloadSchema(auth);
283
+ } catch (cause) {
284
+ // Lazy prime (no existing cache): surface the raw error verbatim
285
+ // (FR-13.5/13.6) — there is no stale cache to keep.
286
+ if (!forceRefresh) throw cause;
287
+ // Forced refresh: atomic writes mean the old JSON is still on
288
+ // disk, so surface a staleness-aware error and leave every cache
289
+ // untouched.
290
+ const surviving = getSchemaMetadata(instanceUrl);
291
+ throw new SchemaRefreshError(buildStaleMessage(orgAlias, surviving), {
292
+ staleSince: surviving?.downloadedAt,
293
+ instanceUrl,
294
+ cause,
295
+ });
296
+ }
297
+ typeCount = meta.typeCount;
298
+ // On a forced refresh, drop this process's in-memory copies so the
299
+ // next read rebuilds from the freshly-downloaded JSON. A lazy prime
300
+ // has nothing stale to clear (this org wasn't cached before).
301
+ //
302
+ // `clearObjectInfoCache` takes the raw alias. Its on-disk path guard
303
+ // (object-info.ts ORG_ALIAS_PATH_RE) is intentionally stricter than the
304
+ // `org` charset `connect` accepts — it gates a filesystem path, so it
305
+ // must reject `.`/`@`/`+` to stay traversal-safe. For an email-shaped
306
+ // alias the disk clear no-ops, but that is safe: the ObjectInfo disk
307
+ // *write* uses the same strict guard, so no on-disk entry can exist for
308
+ // such an alias; the in-memory clear (no guard) always runs. Do NOT
309
+ // loosen the path guard to "align" the two regexes.
310
+ if (forceRefresh) {
311
+ clearSchemaCacheByUrl(instanceUrl);
312
+ clearObjectInfoCache(orgAlias);
313
+ }
314
+ primed = true;
315
+ },
316
+ forceRefresh
317
+ ? { skipIf: () => fs.existsSync(filePath) && fs.statSync(filePath).mtimeMs > mtimeAtRequest }
318
+ : undefined,
319
+ );
189
320
 
190
321
  if (!primed) {
191
- // Another process primed while we were waiting.
192
- return { cached: true, filePath, durationMs: Date.now() - start, instanceUrl };
322
+ // Either a lazy prime found the file already present, or a concurrent
323
+ // refresh produced the new schema while we waited (coalesced). In the
324
+ // coalesced-refresh case a long-lived process (the MCP server) may still
325
+ // hold stale in-memory copies of the OLD schema, so drop them now that
326
+ // disk is fresh — the next read rebuilds from the peer's download.
327
+ if (forceRefresh && fs.existsSync(filePath) && fs.statSync(filePath).mtimeMs > mtimeAtRequest) {
328
+ clearSchemaCacheByUrl(instanceUrl);
329
+ clearObjectInfoCache(orgAlias);
330
+ }
331
+ return {
332
+ cached: true,
333
+ refreshed: false,
334
+ filePath,
335
+ durationMs: Date.now() - start,
336
+ instanceUrl,
337
+ };
193
338
  }
194
- return { cached: false, filePath, durationMs: Date.now() - start, instanceUrl };
339
+ return {
340
+ cached: false,
341
+ refreshed: forceRefresh,
342
+ filePath,
343
+ durationMs: Date.now() - start,
344
+ instanceUrl,
345
+ typeCount,
346
+ };
195
347
  }
package/src/lib/walker.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  type GraphQLOutputType,
29
29
  type GraphQLType,
30
30
  } from "graphql";
31
+ import { atomicWriteText } from "./fs-utils.js";
31
32
  import { loadIntrospectionResult, getSchemaFilePath, normalizeInstanceUrl } from "./introspect.js";
32
33
  import { type OperationType } from "./session.js";
33
34
 
@@ -122,9 +123,13 @@ function buildSchemaWithSdlCache(introspectionFilePath: string): GraphQLSchema {
122
123
  patchEmptyInputObjects(data);
123
124
  const schema = buildClientSchema(data);
124
125
 
125
- // Persist SDL for the next invocation.
126
+ // Persist SDL for the next invocation. Atomic temp+rename (not a bare
127
+ // writeFileSync) so a crash mid-write or a concurrent CLI+MCP writer can't
128
+ // leave a torn `.graphql` — readers see either the old or the fully-written
129
+ // file. (The read path also self-heals from the atomic JSON, but this avoids
130
+ // the wasted rebuild.)
126
131
  try {
127
- fs.writeFileSync(sdlPath, printSchema(schema), "utf-8");
132
+ atomicWriteText(sdlPath, printSchema(schema));
128
133
  } catch {
129
134
  // Non-critical — writable filesystem is not guaranteed.
130
135
  }
@@ -170,6 +175,25 @@ export function clearSchemaCache(instanceUrl?: string): void {
170
175
  }
171
176
  }
172
177
 
178
+ /**
179
+ * Evict the parsed schema for an instance URL AND its derived on-disk SDL
180
+ * side-file (`<cacheKey>.graphql`). Extends {@link clearSchemaCache} (in-memory
181
+ * only) with the SDL removal the refresh path needs: after a forced refresh
182
+ * rewrites the introspection JSON, `buildSchemaWithSdlCache` would otherwise
183
+ * prefer a stale SDL whenever `sdlMtime >= introspMtime` — true on a
184
+ * same-mtime-tick edge — resurrecting the old schema on the next cold read and
185
+ * defeating the refresh's coherence guarantee. SDL removal is best-effort.
186
+ */
187
+ export function clearSchemaCacheByUrl(instanceUrl: string): void {
188
+ clearSchemaCache(instanceUrl);
189
+ try {
190
+ const sdlPath = getSchemaFilePath(instanceUrl).replace(/\.json$/, ".graphql");
191
+ fs.rmSync(sdlPath, { force: true });
192
+ } catch {
193
+ // best-effort; a stale SDL only matters on the rare same-mtime-tick edge.
194
+ }
195
+ }
196
+
173
197
  export function primeSchemaCache(alias: string, schema: GraphQLSchema): void {
174
198
  schemaCache.set(alias, schema);
175
199
  }
@@ -50,6 +50,7 @@ describe("mcp/server", () => {
50
50
  expect(names).toEqual([
51
51
  "echo",
52
52
  "sf_gql_aggregate",
53
+ "sf_gql_connect",
53
54
  "sf_gql_create",
54
55
  "sf_gql_delete",
55
56
  "sf_gql_detail",
package/src/mcp/server.ts CHANGED
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { registerEchoTool } from "./tools/echo.js";
10
10
  import { registerSfGqlAggregateTool } from "./tools/sf-gql-aggregate.js";
11
+ import { registerSfGqlConnectTool } from "./tools/sf-gql-connect.js";
11
12
  import { registerSfGqlCreateTool } from "./tools/sf-gql-create.js";
12
13
  import { registerSfGqlDeleteTool } from "./tools/sf-gql-delete.js";
13
14
  import { registerSfGqlDetailTool } from "./tools/sf-gql-detail.js";
@@ -27,6 +28,7 @@ export function createServer(): McpServer {
27
28
  });
28
29
  registerEchoTool(server);
29
30
  registerSfGqlAggregateTool(server);
31
+ registerSfGqlConnectTool(server);
30
32
  registerSfGqlCreateTool(server);
31
33
  registerSfGqlDeleteTool(server);
32
34
  registerSfGqlDetailTool(server);
@@ -0,0 +1,202 @@
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 fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
11
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { buildSchema } from "graphql";
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+ import { makeNoopPrimeDeps } from "../../../__tests__/helpers/prime-deps.js";
16
+ import { type PrimeDeps } from "../../../lib/prime-schema.js";
17
+ import { registerSfGqlConnectTool } from "../sf-gql-connect.js";
18
+
19
+ const ORG = "test-tool-connect";
20
+ const ORG_URL = "https://test-tool-connect.my.salesforce.com";
21
+ const SCHEMA = buildSchema(`type Query { x: Int }`);
22
+
23
+ // Succeeds on the first download (prime), then fails every subsequent one — lets
24
+ // a single server prime a real cache and then fail a refresh over it.
25
+ function flakyPrimeDeps(): PrimeDeps {
26
+ const noop = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
27
+ let n = 0;
28
+ return {
29
+ getOrgAuth: noop.getOrgAuth,
30
+ downloadSchema: async (auth) => {
31
+ n++;
32
+ if (n === 1) return noop.downloadSchema(auth);
33
+ throw new Error("introspection failed");
34
+ },
35
+ };
36
+ }
37
+
38
+ function failingPrimeDeps(): PrimeDeps {
39
+ const noop = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA);
40
+ return {
41
+ getOrgAuth: noop.getOrgAuth,
42
+ downloadSchema: async () => {
43
+ throw new Error("introspection failed");
44
+ },
45
+ };
46
+ }
47
+
48
+ interface ParsedConnect {
49
+ org: string;
50
+ instanceUrl: string;
51
+ refreshed: boolean;
52
+ cached: boolean;
53
+ durationMs: number;
54
+ warnings?: string[];
55
+ }
56
+
57
+ async function connect(
58
+ primeDeps: PrimeDeps = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA),
59
+ ): Promise<{ client: Client; server: McpServer }> {
60
+ const server = new McpServer({ name: "graphiti-mcp", version: "test" });
61
+ registerSfGqlConnectTool(server, { primeDeps });
62
+ const [c, s] = InMemoryTransport.createLinkedPair();
63
+ const client = new Client({ name: "test", version: "0.0.0" });
64
+ await Promise.all([server.connect(s), client.connect(c)]);
65
+ return { client, server };
66
+ }
67
+
68
+ function parse(result: Awaited<ReturnType<Client["callTool"]>>): ParsedConnect {
69
+ const content = result.content as { type: string; text?: string }[];
70
+ return JSON.parse(content[0]?.text ?? "{}") as ParsedConnect;
71
+ }
72
+
73
+ describe("mcp/tools/sf-gql-connect", () => {
74
+ let tmpRoot: string;
75
+
76
+ beforeEach(() => {
77
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-tool-connect-"));
78
+ process.env.GRAPHITI_HOME = tmpRoot;
79
+ });
80
+
81
+ afterEach(() => {
82
+ delete process.env.GRAPHITI_HOME;
83
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
84
+ });
85
+
86
+ it("tools/list advertises sf_gql_connect with org/forceRefresh properties", async () => {
87
+ const { client, server } = await connect();
88
+ try {
89
+ const list = await client.listTools();
90
+ const tool = list.tools.find((t) => t.name === "sf_gql_connect");
91
+ expect(tool).toBeDefined();
92
+ const props =
93
+ (tool!.inputSchema as { properties?: Record<string, unknown> }).properties ?? {};
94
+ expect(props.org).toBeDefined();
95
+ expect(props.forceRefresh).toBeDefined();
96
+ } finally {
97
+ await client.close();
98
+ await server.close();
99
+ }
100
+ });
101
+
102
+ it("primes on first connect and reports cached on the second (forceRefresh defaults to false)", async () => {
103
+ const { client, server } = await connect();
104
+ try {
105
+ const first = parse(
106
+ await client.callTool({ name: "sf_gql_connect", arguments: { org: ORG } }),
107
+ );
108
+ expect(first.cached).toBe(false);
109
+ expect(first.refreshed).toBe(false);
110
+ expect(first.instanceUrl).toBe(ORG_URL);
111
+
112
+ const second = parse(
113
+ await client.callTool({ name: "sf_gql_connect", arguments: { org: ORG } }),
114
+ );
115
+ expect(second.cached).toBe(true);
116
+ expect(second.refreshed).toBe(false);
117
+ } finally {
118
+ await client.close();
119
+ await server.close();
120
+ }
121
+ });
122
+
123
+ it("forceRefresh:true re-downloads and reports refreshed:true", async () => {
124
+ const { client, server } = await connect();
125
+ try {
126
+ await client.callTool({ name: "sf_gql_connect", arguments: { org: ORG } }); // prime
127
+ const refreshed = parse(
128
+ await client.callTool({
129
+ name: "sf_gql_connect",
130
+ arguments: { org: ORG, forceRefresh: true },
131
+ }),
132
+ );
133
+ expect(refreshed.refreshed).toBe(true);
134
+ expect(refreshed.cached).toBe(false);
135
+ } finally {
136
+ await client.close();
137
+ await server.close();
138
+ }
139
+ });
140
+
141
+ it("refresh failure over a surviving cache returns a soft staleness warning (not isError)", async () => {
142
+ const { client, server } = await connect(flakyPrimeDeps());
143
+ try {
144
+ await client.callTool({ name: "sf_gql_connect", arguments: { org: ORG } }); // prime (ok)
145
+ const result = await client.callTool({
146
+ name: "sf_gql_connect",
147
+ arguments: { org: ORG, forceRefresh: true }, // download now fails
148
+ });
149
+ expect(result.isError).toBeFalsy();
150
+ const out = parse(result);
151
+ expect(out.refreshed).toBe(false);
152
+ expect(out.cached).toBe(true);
153
+ expect(out.warnings?.[0]).toMatch(/keeping the previously cached schema/i);
154
+ } finally {
155
+ await client.close();
156
+ await server.close();
157
+ }
158
+ });
159
+
160
+ it("refresh failure with no prior cache surfaces an isError envelope", async () => {
161
+ const { client, server } = await connect(failingPrimeDeps());
162
+ try {
163
+ const result = await client.callTool({
164
+ name: "sf_gql_connect",
165
+ arguments: { org: ORG, forceRefresh: true },
166
+ });
167
+ expect(result.isError).toBe(true);
168
+ } finally {
169
+ await client.close();
170
+ await server.close();
171
+ }
172
+ });
173
+
174
+ it("tools/call with missing org returns a validation error", async () => {
175
+ const { client, server } = await connect();
176
+ try {
177
+ const result = await client.callTool({ name: "sf_gql_connect", arguments: {} });
178
+ expect(result.isError).toBe(true);
179
+ const content = result.content as { type: string; text?: string }[];
180
+ expect(content[0]?.text ?? "").toMatch(/org/);
181
+ } finally {
182
+ await client.close();
183
+ await server.close();
184
+ }
185
+ });
186
+
187
+ it("rejects an org alias containing shell metacharacters", async () => {
188
+ const { client, server } = await connect();
189
+ try {
190
+ const result = await client.callTool({
191
+ name: "sf_gql_connect",
192
+ arguments: { org: "evil; rm -rf /" },
193
+ });
194
+ expect(result.isError).toBe(true);
195
+ const content = result.content as { type: string; text?: string }[];
196
+ expect(content[0]?.text ?? "").toMatch(/org/);
197
+ } finally {
198
+ await client.close();
199
+ await server.close();
200
+ }
201
+ });
202
+ });
@@ -0,0 +1,42 @@
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 type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { z } from "zod";
9
+ import { orgAlias } from "./shared/zod-schemas.js";
10
+ import { buildConnect, type ConnectDeps } from "../../intent/build-connect.js";
11
+
12
+ export type SfGqlConnectToolOptions = ConnectDeps;
13
+
14
+ const inputSchema = {
15
+ org: orgAlias("Org alias or username resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."),
16
+ forceRefresh: z
17
+ .boolean()
18
+ .optional()
19
+ .describe(
20
+ "Re-download the schema even if it is already cached, clearing the on-disk introspection JSON, the in-memory parsed schema, and the ObjectInfo cache. Use after deploying new metadata (fields, picklist values, objects) so subsequent tools see the changes. Defaults to false.",
21
+ ),
22
+ };
23
+
24
+ export function registerSfGqlConnectTool(
25
+ server: McpServer,
26
+ opts: SfGqlConnectToolOptions = {},
27
+ ): void {
28
+ server.registerTool(
29
+ "sf_gql_connect",
30
+ {
31
+ description:
32
+ "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.",
33
+ inputSchema,
34
+ },
35
+ async (args) => {
36
+ const output = await buildConnect(args, opts);
37
+ return {
38
+ content: [{ type: "text", text: JSON.stringify(output) }],
39
+ };
40
+ },
41
+ );
42
+ }