@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.
- package/AGENT_GUIDE.md +23 -0
- package/CHANGELOG.md +6 -0
- package/README.md +17 -6
- package/dist/commands/connect.d.ts +2 -1
- package/dist/commands/connect.js +28 -7
- package/dist/commands/connect.js.map +1 -1
- package/dist/intent/build-connect.d.ts +24 -0
- package/dist/intent/build-connect.js +47 -0
- package/dist/intent/build-connect.js.map +1 -0
- package/dist/intent/types.d.ts +23 -0
- package/dist/lib/fs-utils.d.ts +3 -1
- package/dist/lib/fs-utils.js +7 -3
- package/dist/lib/fs-utils.js.map +1 -1
- package/dist/lib/introspect.js +20 -1
- package/dist/lib/introspect.js.map +1 -1
- package/dist/lib/prime-schema.d.ts +70 -16
- package/dist/lib/prime-schema.js +166 -33
- package/dist/lib/prime-schema.js.map +1 -1
- package/dist/lib/walker.d.ts +10 -0
- package/dist/lib/walker.js +26 -2
- package/dist/lib/walker.js.map +1 -1
- package/dist/mcp/server.js +2 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/sf-gql-connect.d.ts +9 -0
- package/dist/mcp/tools/sf-gql-connect.js +27 -0
- package/dist/mcp/tools/sf-gql-connect.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/helpers/object-info.ts +37 -0
- package/src/commands/__tests__/connect.spec.ts +92 -0
- package/src/commands/connect.ts +28 -6
- package/src/intent/__tests__/build-connect.spec.ts +103 -0
- package/src/intent/build-connect.ts +55 -0
- package/src/intent/types.ts +25 -0
- package/src/lib/__tests__/introspect.spec.ts +34 -0
- package/src/lib/__tests__/prime-schema.spec.ts +341 -0
- package/src/lib/__tests__/walker.spec.ts +13 -0
- package/src/lib/fs-utils.ts +8 -3
- package/src/lib/introspect.ts +29 -6
- package/src/lib/prime-schema.ts +184 -32
- package/src/lib/walker.ts +26 -2
- package/src/mcp/__tests__/server.spec.ts +1 -0
- package/src/mcp/server.ts +2 -0
- package/src/mcp/tools/__tests__/sf-gql-connect.spec.ts +202 -0
- package/src/mcp/tools/sf-gql-connect.ts +42 -0
package/src/lib/prime-schema.ts
CHANGED
|
@@ -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
|
|
37
|
-
* is skipped (the other process already did it).
|
|
38
|
-
*
|
|
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 `
|
|
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
|
|
66
|
-
if (
|
|
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
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
if (
|
|
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
|
|
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
|
|
154
|
-
*
|
|
155
|
-
* `
|
|
156
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
//
|
|
192
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
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
|
+
}
|