@salesforce/graphiti 11.13.2 → 11.14.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/CHANGELOG.md +6 -0
- package/dist/intent/build-list.js +7 -6
- package/dist/intent/build-list.js.map +1 -1
- package/dist/intent/select-child-relationship.js +7 -6
- package/dist/intent/select-child-relationship.js.map +1 -1
- package/dist/intent/types.d.ts +6 -6
- package/dist/lib/variable-promotion.d.ts +26 -0
- package/dist/lib/variable-promotion.js +43 -0
- package/dist/lib/variable-promotion.js.map +1 -1
- package/dist/schemas/fields.d.ts +41 -12
- package/dist/schemas/fields.js +57 -7
- package/dist/schemas/fields.js.map +1 -1
- package/dist/schemas/input-schemas.d.ts +35 -35
- package/dist/schemas/input-schemas.js +19 -15
- package/dist/schemas/input-schemas.js.map +1 -1
- package/package.json +1 -1
- package/src/intent/__tests__/build-detail.spec.ts +54 -0
- package/src/intent/__tests__/build-list.spec.ts +59 -0
- package/src/intent/build-list.ts +21 -6
- package/src/intent/select-child-relationship.ts +27 -6
- package/src/intent/types.ts +6 -6
- package/src/lib/__tests__/variable-promotion.spec.ts +51 -1
- package/src/lib/variable-promotion.ts +62 -0
- package/src/mcp/tools/__tests__/sf-gql-detail.spec.ts +5 -4
- package/src/mcp/tools/__tests__/sf-gql-list.spec.ts +287 -1
- package/src/schemas/fields.ts +66 -7
- package/src/schemas/input-schemas.ts +37 -15
|
@@ -25,12 +25,16 @@ const ORG_URL = "https://test-tool-list.my.salesforce.com";
|
|
|
25
25
|
const SCHEMA = buildSchema(`
|
|
26
26
|
type Query { uiapi: UIAPI! }
|
|
27
27
|
type UIAPI { query: RecordQuery! }
|
|
28
|
-
type RecordQuery { Account(first: Int, after: String): AccountConnection! }
|
|
28
|
+
type RecordQuery { Account(first: Int, after: String, where: Account_Filter, orderBy: Account_OrderBy): AccountConnection! }
|
|
29
29
|
type AccountConnection { edges: [AccountEdge!]!, pageInfo: PageInfo! }
|
|
30
30
|
type AccountEdge { node: Account! }
|
|
31
31
|
type PageInfo { hasNextPage: Boolean!, endCursor: String }
|
|
32
32
|
type Account { Id: ID!, Name: StringValue }
|
|
33
33
|
type StringValue { value: String }
|
|
34
|
+
input Account_Filter { Name: StringOperators }
|
|
35
|
+
input StringOperators { eq: String }
|
|
36
|
+
input Account_OrderBy { Name: OrderByValue }
|
|
37
|
+
input OrderByValue { order: String }
|
|
34
38
|
`);
|
|
35
39
|
primeSchemaCache(ORG, SCHEMA);
|
|
36
40
|
primeSchemaCache(ORG_URL, SCHEMA);
|
|
@@ -148,4 +152,286 @@ describe("mcp/tools/sf-gql-list", () => {
|
|
|
148
152
|
await server.close();
|
|
149
153
|
}
|
|
150
154
|
});
|
|
155
|
+
|
|
156
|
+
it("accepts a whole-argument $filter string and renders where: $filter", async () => {
|
|
157
|
+
const { client, server } = await connect();
|
|
158
|
+
try {
|
|
159
|
+
const res = await client.callTool({
|
|
160
|
+
name: "sf_gql_list",
|
|
161
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], filter: "$filter" },
|
|
162
|
+
});
|
|
163
|
+
const content = res.content as { type: string; text?: string }[];
|
|
164
|
+
const out = JSON.parse(content[0]?.text ?? "{}") as {
|
|
165
|
+
query: string;
|
|
166
|
+
variables: { name: string }[];
|
|
167
|
+
};
|
|
168
|
+
expect(out.query).toMatch(/where\s*:\s*\$filter\b/);
|
|
169
|
+
expect(out.variables.find((v) => v.name === "filter")).toBeDefined();
|
|
170
|
+
} finally {
|
|
171
|
+
await client.close();
|
|
172
|
+
await server.close();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rejects an invalid whole-argument placeholder", async () => {
|
|
177
|
+
const { client, server } = await connect();
|
|
178
|
+
try {
|
|
179
|
+
const result = await client.callTool({
|
|
180
|
+
name: "sf_gql_list",
|
|
181
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], filter: "$1bad" },
|
|
182
|
+
});
|
|
183
|
+
expect(result.isError).toBe(true);
|
|
184
|
+
} finally {
|
|
185
|
+
await client.close();
|
|
186
|
+
await server.close();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("accepts a whole-argument $first string", async () => {
|
|
191
|
+
const { client, server } = await connect();
|
|
192
|
+
try {
|
|
193
|
+
const res = await client.callTool({
|
|
194
|
+
name: "sf_gql_list",
|
|
195
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], first: "$first" },
|
|
196
|
+
});
|
|
197
|
+
const content = res.content as { type: string; text?: string }[];
|
|
198
|
+
const out = JSON.parse(content[0]?.text ?? "{}") as {
|
|
199
|
+
query: string;
|
|
200
|
+
variables: { name: string }[];
|
|
201
|
+
};
|
|
202
|
+
expect(out.query).toMatch(/first\s*:\s*\$first\b/);
|
|
203
|
+
expect(out.variables.find((v) => v.name === "first")).toBeDefined();
|
|
204
|
+
} finally {
|
|
205
|
+
await client.close();
|
|
206
|
+
await server.close();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("rejects an invalid $first placeholder", async () => {
|
|
211
|
+
const { client, server } = await connect();
|
|
212
|
+
try {
|
|
213
|
+
const result = await client.callTool({
|
|
214
|
+
name: "sf_gql_list",
|
|
215
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], first: "$1bad" },
|
|
216
|
+
});
|
|
217
|
+
expect(result.isError).toBe(true);
|
|
218
|
+
} finally {
|
|
219
|
+
await client.close();
|
|
220
|
+
await server.close();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("tools/list advertises orderBy/filter/first $var branch inline (no lost $ref branches)", async () => {
|
|
225
|
+
const { client, server } = await connect();
|
|
226
|
+
try {
|
|
227
|
+
const list = await client.listTools();
|
|
228
|
+
const tool = list.tools.find((t) => t.name === "sf_gql_list");
|
|
229
|
+
const props = (tool!.inputSchema as { properties: Record<string, unknown> }).properties;
|
|
230
|
+
|
|
231
|
+
// The advertised JSON Schema must be self-contained: the whole serialized
|
|
232
|
+
// schema string must not rely on $ref to express these unions, because the
|
|
233
|
+
// MCP SDK's converter resolves $refs in ways that have dropped the string
|
|
234
|
+
// branch (regressing client-side validation of "$var" inputs).
|
|
235
|
+
const whole = JSON.stringify(tool!.inputSchema);
|
|
236
|
+
expect(whole.includes("$ref")).toBe(false);
|
|
237
|
+
|
|
238
|
+
// Each of these args must advertise a string branch carrying the $var pattern.
|
|
239
|
+
for (const key of ["orderBy", "filter", "first"]) {
|
|
240
|
+
const schema = JSON.stringify(props[key]);
|
|
241
|
+
expect(schema, `${key} must inline a string $var branch`).toMatch(/"type":"string"/);
|
|
242
|
+
expect(schema, `${key} must carry the $var regex pattern`).toContain("\\\\$"); // pattern ^\$...
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
await client.close();
|
|
246
|
+
await server.close();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("accepts a JSON-stringified filter object (model serialization tolerance)", async () => {
|
|
251
|
+
const { client, server } = await connect();
|
|
252
|
+
try {
|
|
253
|
+
const res = await client.callTool({
|
|
254
|
+
name: "sf_gql_list",
|
|
255
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], filter: '{"Name":{"eq":"$q"}}' },
|
|
256
|
+
});
|
|
257
|
+
const content = res.content as { type: string; text?: string }[];
|
|
258
|
+
const out = JSON.parse(content[0]?.text ?? "{}") as {
|
|
259
|
+
query: string;
|
|
260
|
+
variables: { name: string }[];
|
|
261
|
+
};
|
|
262
|
+
// coerced to object, leaf $q promoted
|
|
263
|
+
expect(out.variables.find((v) => v.name === "q")).toBeDefined();
|
|
264
|
+
expect(out.query).toMatch(/Name\s*:\s*\{\s*eq\s*:\s*\$q/);
|
|
265
|
+
} finally {
|
|
266
|
+
await client.close();
|
|
267
|
+
await server.close();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("accepts a JSON-stringified orderBy object", async () => {
|
|
272
|
+
const { client, server } = await connect();
|
|
273
|
+
try {
|
|
274
|
+
const res = await client.callTool({
|
|
275
|
+
name: "sf_gql_list",
|
|
276
|
+
arguments: {
|
|
277
|
+
org: ORG,
|
|
278
|
+
object: "Account",
|
|
279
|
+
fields: ["Id"],
|
|
280
|
+
orderBy: '{"Name":{"order":"DESC"}}',
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const content = res.content as { type: string; text?: string }[];
|
|
284
|
+
const out = JSON.parse(content[0]?.text ?? "{}") as { query: string };
|
|
285
|
+
expect(out.query).toMatch(/orderBy\s*:\s*\{\s*Name/);
|
|
286
|
+
} finally {
|
|
287
|
+
await client.close();
|
|
288
|
+
await server.close();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("accepts a stringified number for first", async () => {
|
|
293
|
+
const { client, server } = await connect();
|
|
294
|
+
try {
|
|
295
|
+
const res = await client.callTool({
|
|
296
|
+
name: "sf_gql_list",
|
|
297
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], first: "25" },
|
|
298
|
+
});
|
|
299
|
+
const content = res.content as { type: string; text?: string }[];
|
|
300
|
+
const out = JSON.parse(content[0]?.text ?? "{}") as { query: string };
|
|
301
|
+
expect(out.query).toMatch(/first\s*:\s*25\b/);
|
|
302
|
+
} finally {
|
|
303
|
+
await client.close();
|
|
304
|
+
await server.close();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("still promotes a whole-arg $var (coercion leaves $var strings alone)", async () => {
|
|
309
|
+
const { client, server } = await connect();
|
|
310
|
+
try {
|
|
311
|
+
const res = await client.callTool({
|
|
312
|
+
name: "sf_gql_list",
|
|
313
|
+
arguments: {
|
|
314
|
+
org: ORG,
|
|
315
|
+
object: "Account",
|
|
316
|
+
fields: ["Id"],
|
|
317
|
+
orderBy: "$orderBy",
|
|
318
|
+
filter: "$filter",
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const content = res.content as { type: string; text?: string }[];
|
|
322
|
+
const out = JSON.parse(content[0]?.text ?? "{}") as { query: string };
|
|
323
|
+
expect(out.query).toMatch(/orderBy\s*:\s*\$orderBy\b/);
|
|
324
|
+
expect(out.query).toMatch(/where\s*:\s*\$filter\b/);
|
|
325
|
+
} finally {
|
|
326
|
+
await client.close();
|
|
327
|
+
await server.close();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("rejects a non-JSON garbage string for filter", async () => {
|
|
332
|
+
const { client, server } = await connect();
|
|
333
|
+
try {
|
|
334
|
+
const result = await client.callTool({
|
|
335
|
+
name: "sf_gql_list",
|
|
336
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], filter: "not json{" },
|
|
337
|
+
});
|
|
338
|
+
expect(result.isError).toBe(true);
|
|
339
|
+
} finally {
|
|
340
|
+
await client.close();
|
|
341
|
+
await server.close();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("rejects pure garbage string for filter (hello)", async () => {
|
|
346
|
+
const { client, server } = await connect();
|
|
347
|
+
try {
|
|
348
|
+
const result = await client.callTool({
|
|
349
|
+
name: "sf_gql_list",
|
|
350
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], filter: "hello" },
|
|
351
|
+
});
|
|
352
|
+
expect(result.isError).toBe(true);
|
|
353
|
+
} finally {
|
|
354
|
+
await client.close();
|
|
355
|
+
await server.close();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("accepts a double-quoted $var placeholder (model double-stringify)", async () => {
|
|
360
|
+
const { client, server } = await connect();
|
|
361
|
+
try {
|
|
362
|
+
const res = await client.callTool({
|
|
363
|
+
name: "sf_gql_list",
|
|
364
|
+
arguments: {
|
|
365
|
+
org: ORG,
|
|
366
|
+
object: "Account",
|
|
367
|
+
fields: ["Id"],
|
|
368
|
+
filter: '"$filter"',
|
|
369
|
+
orderBy: '"$orderBy"',
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
const content = res.content as { type: string; text?: string }[];
|
|
373
|
+
const out = JSON.parse(content[0]?.text ?? "{}") as {
|
|
374
|
+
query: string;
|
|
375
|
+
variables: { name: string }[];
|
|
376
|
+
};
|
|
377
|
+
expect(out.query).toMatch(/where\s*:\s*\$filter\b/);
|
|
378
|
+
expect(out.query).toMatch(/orderBy\s*:\s*\$orderBy\b/);
|
|
379
|
+
expect(out.variables.find((v) => v.name === "filter")).toBeDefined();
|
|
380
|
+
} finally {
|
|
381
|
+
await client.close();
|
|
382
|
+
await server.close();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("accepts a double-quoted first placeholder and a quoted number", async () => {
|
|
387
|
+
const { client, server } = await connect();
|
|
388
|
+
try {
|
|
389
|
+
const res1 = await client.callTool({
|
|
390
|
+
name: "sf_gql_list",
|
|
391
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], first: '"$first"' },
|
|
392
|
+
});
|
|
393
|
+
const content1 = res1.content as { type: string; text?: string }[];
|
|
394
|
+
const out1 = JSON.parse(content1[0]?.text ?? "{}") as { query: string };
|
|
395
|
+
expect(out1.query).toMatch(/first\s*:\s*\$first\b/);
|
|
396
|
+
|
|
397
|
+
const res2 = await client.callTool({
|
|
398
|
+
name: "sf_gql_list",
|
|
399
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], first: '"25"' },
|
|
400
|
+
});
|
|
401
|
+
const content2 = res2.content as { type: string; text?: string }[];
|
|
402
|
+
const out2 = JSON.parse(content2[0]?.text ?? "{}") as { query: string };
|
|
403
|
+
expect(out2.query).toMatch(/first\s*:\s*25\b/);
|
|
404
|
+
} finally {
|
|
405
|
+
await client.close();
|
|
406
|
+
await server.close();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("still rejects quoted garbage", async () => {
|
|
411
|
+
const { client, server } = await connect();
|
|
412
|
+
try {
|
|
413
|
+
const result = await client.callTool({
|
|
414
|
+
name: "sf_gql_list",
|
|
415
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], filter: '"hello"' },
|
|
416
|
+
});
|
|
417
|
+
expect(result.isError).toBe(true);
|
|
418
|
+
} finally {
|
|
419
|
+
await client.close();
|
|
420
|
+
await server.close();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("rejects garbage string for first (abc)", async () => {
|
|
425
|
+
const { client, server } = await connect();
|
|
426
|
+
try {
|
|
427
|
+
const result = await client.callTool({
|
|
428
|
+
name: "sf_gql_list",
|
|
429
|
+
arguments: { org: ORG, object: "Account", fields: ["Id"], first: "abc" },
|
|
430
|
+
});
|
|
431
|
+
expect(result.isError).toBe(true);
|
|
432
|
+
} finally {
|
|
433
|
+
await client.close();
|
|
434
|
+
await server.close();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
151
437
|
});
|
package/src/schemas/fields.ts
CHANGED
|
@@ -7,6 +7,50 @@
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { GRAPHQL_NAME_RE } from "../lib/graphql-name.js";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Some MCP clients/models JSON-stringify complex tool arguments (e.g. send
|
|
12
|
+
* `"25"` for a number or `'{"x":1}'` for an object). This coerces such a
|
|
13
|
+
* string back to its parsed value so the real value validates — lossless,
|
|
14
|
+
* and scoped to strings that LOOK like JSON (object/array/number) so `$var`
|
|
15
|
+
* placeholders and enum strings (e.g. scope "MINE") are left untouched.
|
|
16
|
+
* Same "tolerate predictable client encoding quirks" philosophy as the
|
|
17
|
+
* detailOrderBy array-collapse shim.
|
|
18
|
+
*/
|
|
19
|
+
export const coerceJsonArg = (v: unknown): unknown => {
|
|
20
|
+
const once = coerceJsonOnce(v);
|
|
21
|
+
// Unwrapping a double-quoted string (e.g. '"25"' → "25", '"$first"' → "$first")
|
|
22
|
+
// can yield another coercible string; coerce one more level so '"25"' → 25.
|
|
23
|
+
if (typeof once === "string" && once !== v) return coerceJsonOnce(once);
|
|
24
|
+
return once;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const coerceJsonOnce = (v: unknown): unknown => {
|
|
28
|
+
if (typeof v !== "string") return v;
|
|
29
|
+
const t = v.trim();
|
|
30
|
+
// object/array, bare number, OR a JSON-quoted string literal
|
|
31
|
+
if (!(t.startsWith("{") || t.startsWith("[") || /^-?\d+(\.\d+)?$/.test(t) || t.startsWith('"')))
|
|
32
|
+
return v;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(t);
|
|
35
|
+
} catch {
|
|
36
|
+
return v;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Wrap a schema so a JSON-stringified value is coerced before validation. */
|
|
41
|
+
export const jsonCoercible = (schema: z.ZodTypeAny) => z.preprocess(coerceJsonArg, schema);
|
|
42
|
+
|
|
43
|
+
/** A string that looks like a JSON object/array literal — used so the advertised
|
|
44
|
+
* schema accepts a stringified object/array; coerceJsonArg then parses it. A
|
|
45
|
+
* `{`/`[`-prefixed but invalid-JSON string is accepted as a literal (rare; the
|
|
46
|
+
* model reliably sends valid JSON) rather than erroring. */
|
|
47
|
+
export const jsonLiteralString = () => z.string().regex(/^\s*[{[]/);
|
|
48
|
+
/** A string of digits — a stringified positive integer for `first`. */
|
|
49
|
+
export const intLiteralString = () => z.string().regex(/^\d+$/);
|
|
50
|
+
/** A JSON-quoted string literal, e.g. "\"$first\"" or "\"25\"" — some models
|
|
51
|
+
* double-encode args. coerceJsonArg unwraps it before validation. */
|
|
52
|
+
export const quotedString = () => z.string().regex(/^".*"$/s);
|
|
53
|
+
|
|
10
54
|
/**
|
|
11
55
|
* Zod string validator that enforces the GraphQL Name production. Returned
|
|
12
56
|
* schema is `.describe()`-tagged with the supplied description so it appears
|
|
@@ -26,11 +70,20 @@ export const orgAlias = (description: string): z.ZodString =>
|
|
|
26
70
|
z.string().regex(ORG_ALIAS_RE, "must be a valid org alias or username").describe(description);
|
|
27
71
|
|
|
28
72
|
/**
|
|
29
|
-
* `<Object>_OrderBy` shape.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
73
|
+
* `<Object>_OrderBy` shape. A FACTORY (not a shared instance): each call returns
|
|
74
|
+
* a fresh schema so the MCP SDK's zod-to-json-schema converter INLINES it at every
|
|
75
|
+
* use site instead of collapsing reuse into cross-`$ref`s (which intermittently
|
|
76
|
+
* dropped union branches in the advertised schema).
|
|
77
|
+
*/
|
|
78
|
+
export const orderByObject = () => z.record(z.unknown());
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A bare top-level `$varName` string standing in for an entire `filter` /
|
|
82
|
+
* `orderBy` / `first` argument. A FACTORY (not a shared instance) so the advertised
|
|
83
|
+
* JSON Schema inlines it at every use site rather than emitting cross-`$ref`s.
|
|
32
84
|
*/
|
|
33
|
-
export const
|
|
85
|
+
export const varPlaceholder = () =>
|
|
86
|
+
z.string().regex(/^\$[A-Za-z_]\w*$/, "must be a $variable placeholder, e.g. $filter");
|
|
34
87
|
|
|
35
88
|
/**
|
|
36
89
|
* Build a `childRelationships[]` element schema. The `orderBy` schema is a
|
|
@@ -49,7 +102,13 @@ export const childRelationshipSchema = (orderBy: z.ZodTypeAny) =>
|
|
|
49
102
|
'Child relationship API name, e.g. "Contacts", "Opportunities". Must be a valid GraphQL Name.',
|
|
50
103
|
),
|
|
51
104
|
fields: z.array(z.string()),
|
|
52
|
-
first:
|
|
53
|
-
|
|
54
|
-
|
|
105
|
+
first: jsonCoercible(
|
|
106
|
+
z.union([z.number().int().positive(), varPlaceholder(), intLiteralString(), quotedString()]),
|
|
107
|
+
).optional(),
|
|
108
|
+
filter: jsonCoercible(
|
|
109
|
+
z.union([z.record(z.unknown()), varPlaceholder(), jsonLiteralString(), quotedString()]),
|
|
110
|
+
).optional(),
|
|
111
|
+
orderBy: jsonCoercible(
|
|
112
|
+
z.union([orderBy, varPlaceholder(), jsonLiteralString(), quotedString()]),
|
|
113
|
+
).optional(),
|
|
55
114
|
});
|
|
@@ -17,14 +17,32 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { z } from "zod";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
childRelationshipSchema,
|
|
22
|
+
graphqlName,
|
|
23
|
+
intLiteralString,
|
|
24
|
+
jsonCoercible,
|
|
25
|
+
jsonLiteralString,
|
|
26
|
+
orderByObject,
|
|
27
|
+
orgAlias,
|
|
28
|
+
quotedString,
|
|
29
|
+
varPlaceholder,
|
|
30
|
+
} from "./fields.js";
|
|
21
31
|
import { GROUP_BY_FUNCTIONS } from "../intent/types.js";
|
|
22
32
|
|
|
23
33
|
// --- sf_gql_list ------------------------------------------------------------
|
|
24
34
|
|
|
25
35
|
// `sf_gql_list` advertises both shapes (singleton + array) for backward
|
|
26
36
|
// compatibility with early MCP clients that learned the array form.
|
|
27
|
-
|
|
37
|
+
// Factory function to avoid cross-$ref deduplication.
|
|
38
|
+
const listOrderBySchema = () =>
|
|
39
|
+
z.union([
|
|
40
|
+
orderByObject(),
|
|
41
|
+
z.array(orderByObject()),
|
|
42
|
+
varPlaceholder(),
|
|
43
|
+
jsonLiteralString(),
|
|
44
|
+
quotedString(),
|
|
45
|
+
]);
|
|
28
46
|
|
|
29
47
|
export const LIST_INPUT = z.object({
|
|
30
48
|
org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."),
|
|
@@ -38,24 +56,26 @@ export const LIST_INPUT = z.object({
|
|
|
38
56
|
.array(z.string())
|
|
39
57
|
.optional()
|
|
40
58
|
.describe('Dotted parent-relationship paths, e.g. "Account.Name".'),
|
|
41
|
-
childRelationships: z.array(childRelationshipSchema(listOrderBySchema)).optional(),
|
|
42
|
-
filter:
|
|
43
|
-
.record(z.unknown())
|
|
59
|
+
childRelationships: z.array(childRelationshipSchema(listOrderBySchema())).optional(),
|
|
60
|
+
filter: jsonCoercible(
|
|
61
|
+
z.union([z.record(z.unknown()), varPlaceholder(), jsonLiteralString(), quotedString()]),
|
|
62
|
+
)
|
|
44
63
|
.optional()
|
|
45
64
|
.describe(
|
|
46
|
-
|
|
65
|
+
'<Object>_Filter shape — pass as a JSON OBJECT (e.g. {"Status":{"eq":"New"}}), not a JSON-stringified string. String leaves matching $varName promote to typed query variables. Pass a single "$varName" string to promote the WHOLE filter to one optional <Object>_Filter variable (bind it to null for "no filter → all rows").',
|
|
47
66
|
),
|
|
48
|
-
orderBy: listOrderBySchema
|
|
67
|
+
orderBy: jsonCoercible(listOrderBySchema())
|
|
49
68
|
.optional()
|
|
50
69
|
.describe(
|
|
51
|
-
|
|
70
|
+
'<Object>_OrderBy — pass as a JSON OBJECT (e.g. {"CreatedDate":{"order":"DESC"}}), not a JSON-stringified string. Singleton object preferred; arrays are collapsed to the first entry. Pass a single "$varName" string to promote the whole orderBy to one optional <Object>_OrderBy variable.',
|
|
52
71
|
),
|
|
53
|
-
first:
|
|
54
|
-
.number()
|
|
55
|
-
|
|
56
|
-
.positive()
|
|
72
|
+
first: jsonCoercible(
|
|
73
|
+
z.union([z.number().int().positive(), varPlaceholder(), intLiteralString(), quotedString()]),
|
|
74
|
+
)
|
|
57
75
|
.optional()
|
|
58
|
-
.describe(
|
|
76
|
+
.describe(
|
|
77
|
+
'Top-level connection page size as a NUMBER (e.g. 25, not "25"); defaults to 10. Pass a single "$varName" string to promote it to an optional Int variable.',
|
|
78
|
+
),
|
|
59
79
|
scope: z.string().optional().describe('Scope enum (e.g. "MINE", "EVERYTHING") or $varName.'),
|
|
60
80
|
operationName: graphqlName(
|
|
61
81
|
"Override the GraphQL operation name. Defaults to <Object>List.",
|
|
@@ -68,7 +88,9 @@ export const LIST_INPUT = z.object({
|
|
|
68
88
|
// arrays at runtime via `z.preprocess` (FR-6.2 compat shim for early MCP
|
|
69
89
|
// clients that learned the array shape). The transform collapses to the first
|
|
70
90
|
// element before validation, so downstream code sees a singleton.
|
|
71
|
-
|
|
91
|
+
// Factory function to avoid cross-$ref deduplication.
|
|
92
|
+
const detailOrderBySchema = () =>
|
|
93
|
+
z.preprocess((v) => (Array.isArray(v) ? v[0] : v), orderByObject());
|
|
72
94
|
|
|
73
95
|
// Note: filter/orderBy/scope/first are intentionally absent at the top level —
|
|
74
96
|
// `sf_gql_detail` is single-record-by-Id (FR-5.5). Filtering happens via the
|
|
@@ -90,7 +112,7 @@ export const DETAIL_INPUT = z
|
|
|
90
112
|
.array(z.string())
|
|
91
113
|
.optional()
|
|
92
114
|
.describe('Dotted parent-relationship paths, e.g. "Account.Name".'),
|
|
93
|
-
childRelationships: z.array(childRelationshipSchema(detailOrderBySchema)).optional(),
|
|
115
|
+
childRelationships: z.array(childRelationshipSchema(detailOrderBySchema())).optional(),
|
|
94
116
|
idVariable: graphqlName(
|
|
95
117
|
'Name (without leading "$") for the ID variable. Defaults to "id". Declared as <name>: ID! and bound via where { Id: { eq: $<name> } } per FR-5.5. Choose a name that does not appear as a $varName in any childRelationships filter/orderBy — collisions are rejected.',
|
|
96
118
|
).optional(),
|