/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /** * Per-tool Zod input schemas, extracted so the MCP server and the CLI mirror * (`src/commands/mcp-mirror/`) validate identical argument shapes. The MCP * tools consume `_INPUT.shape` (raw-shape registration) or the object * directly (`detail`, which is `.strict()`); the CLI adapters call * `_INPUT.parse()` on the incoming JSON. Single source of truth keeps * the two transports arg-for-arg symmetric. * * Descriptions and validation are preserved verbatim from the original inline * schemas; behavior is unchanged — this is a pure extraction. */ import { z } from "zod"; import { childRelationshipSchema, dottedGraphqlName, enumStripControlChars, graphqlName, intLiteralString, jsonCoercible, jsonLiteralString, orderByObject, orgAlias, quotedString, scopeArg, varPlaceholder, } from "./fields.js"; import { GROUP_BY_FUNCTIONS } from "../intent/types.js"; // --- sf_gql_list ------------------------------------------------------------ // `sf_gql_list` advertises both shapes (singleton + array) for backward // compatibility with early MCP clients that learned the array form. // Factory function to avoid cross-$ref deduplication. const listOrderBySchema = () => z.union([ orderByObject(), z.array(orderByObject()), varPlaceholder(), jsonLiteralString(), quotedString(), ]); export const LIST_INPUT = z.object({ org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), object: graphqlName('SObject API name, e.g. "Account", "Case". Must be a valid GraphQL Name.'), fields: z .array(dottedGraphqlName('Scalar field API name; dot-paths like "Owner.Name" allowed.')) .describe( 'Scalar field API names to select on the node. Dot-paths like "Owner.Name" are allowed.', ), parentFields: z .array(dottedGraphqlName('Dotted parent-relationship path, e.g. "Account.Name".')) .optional() .describe('Dotted parent-relationship paths, e.g. "Account.Name".'), childRelationships: z.array(childRelationshipSchema(listOrderBySchema())).optional(), filter: jsonCoercible( z.union([z.record(z.unknown()), varPlaceholder(), jsonLiteralString(), quotedString()]), ) .optional() .describe( '_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 _Filter variable (bind it to null for "no filter → all rows").', ), orderBy: jsonCoercible(listOrderBySchema()) .optional() .describe( '_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 _OrderBy variable.', ), first: jsonCoercible( z.union([z.number().int().positive(), varPlaceholder(), intLiteralString(), quotedString()]), ) .optional() .describe( '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.', ), scope: scopeArg('Scope enum (e.g. "MINE", "EVERYTHING") or $varName.').optional(), operationName: graphqlName( "Override the GraphQL operation name. Defaults to List.", ).optional(), }); // --- sf_gql_detail ---------------------------------------------------------- // Advertised schema per FR-6.3 is the singleton object form; we still accept // arrays at runtime via `z.preprocess` (FR-6.2 compat shim for early MCP // clients that learned the array shape). The transform collapses to the first // element before validation, so downstream code sees a singleton. // Factory function to avoid cross-$ref deduplication. const detailOrderBySchema = () => z.preprocess((v) => (Array.isArray(v) ? v[0] : v), orderByObject()); // Note: filter/orderBy/scope/first are intentionally absent at the top level — // `sf_gql_detail` is single-record-by-Id (FR-5.5). Filtering happens via the // declared `$: ID!`; pagination doesn't apply. // // `.strict()` rejects unknown top-level keys at the MCP boundary so an LLM // hallucinating sibling-tool params (`filter`, `scope`, `first`) gets a // validation error to learn from rather than silent omission. export const DETAIL_INPUT = z .object({ org: orgAlias("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), object: graphqlName('SObject API name, e.g. "Account", "Case". Must be a valid GraphQL Name.'), fields: z .array(dottedGraphqlName('Scalar field API name; dot-paths like "Owner.Name" allowed.')) .describe( 'Scalar field API names to select on the node. Dot-paths like "Owner.Name" are allowed.', ), parentFields: z .array(dottedGraphqlName('Dotted parent-relationship path, e.g. "Account.Name".')) .optional() .describe('Dotted parent-relationship paths, e.g. "Account.Name".'), childRelationships: z.array(childRelationshipSchema(detailOrderBySchema())).optional(), idVariable: graphqlName( 'Name (without leading "$") for the ID variable. Defaults to "id". Declared as : ID! and bound via where { Id: { eq: $ } } per FR-5.5. Choose a name that does not appear as a $varName in any childRelationships filter/orderBy — collisions are rejected.', ).optional(), operationName: graphqlName( "Override the GraphQL operation name. Defaults to Detail.", ).optional(), }) .strict(); // --- sf_gql_discover -------------------------------------------------------- // Org/object/field flow into shell commands (sf CLI), filesystem paths // (ObjectInfo cache), and GraphQL operation names. Bound at the MCP boundary // to safe API-name charsets so downstream layers can trust them. const DISCOVER_ORG_ALIAS_RE = /^[A-Za-z0-9_-]{1,80}$/; const DISCOVER_SOBJECT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,79}$/; // Mirrors CONTROL_CHAR_RE's Cc/Cf class (lib/control-chars.ts): a search // string carrying bidi overrides / zero-width / tag chars (all Cf) is the same // host-reflection threat as the C0/DEL (Cc) chars the earlier range caught — // `search` is reflected verbatim through the SUCCESS envelope // (tool-adapter.ts runTool), which neutralizes neither Cc nor Cf (W-23336442). // The Unicode property escape tracks future Cc/Cf additions so this guard // cannot drift from the canonical class. Unlike the enum-rejection stripper, // this REJECTS rather than strips: `search` is a free-text substring filter, // not an enum, so there is no allowed-value round-trip to preserve. The `u` // flag is REQUIRED for the \p escapes. // // Applied via `.refine()` (below), NOT `.regex()`, on purpose: `.regex()` would // serialize this into the tool's advertised JSON-Schema as a `pattern` keyword, // and a `\p{...}` property escape — legal in ECMAScript/RE2 but NOT in Python's // `re` — makes any Python-side JSON-Schema consumer (e.g. an eval harness that // validates tool-call arguments with `jsonschema`) throw `bad escape \p` on // EVERY discover call. `.refine()` enforces the same rule at runtime here in // Node while emitting no `pattern`, so the schema stays cross-runtime portable. const DISCOVER_SEARCH_RE = /^[^\p{Cc}\p{Cf}]*$/u; export const DISCOVER_INPUT = z.object({ org: z .string() .regex(DISCOVER_ORG_ALIAS_RE, "org must match /^[A-Za-z0-9_-]{1,80}$/") .describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), // enumStripControlChars: a bad `mode` is reflected verbatim by the SDK's // upstream validation (W-23336443) — strip Cc/Cf before it can reach the host. mode: enumStripControlChars( z.enum(["list_objects", "describe_object", "describe_field"]), ).describe( 'Discovery mode. "list_objects" enumerates queryable SObjects; "describe_object" and "describe_field" return ObjectInfo metadata.', ), object: z .string() .regex(DISCOVER_SOBJECT_NAME_RE, "object must match /^[A-Za-z][A-Za-z0-9_]{0,79}$/") .optional() .describe('SObject API name. Required for "describe_object" and "describe_field".'), field: z .string() .regex(DISCOVER_SOBJECT_NAME_RE, "field must match /^[A-Za-z][A-Za-z0-9_]{0,79}$/") .optional() .describe('Field API name. Required for "describe_field".'), search: z .string() .max(100, "search must be 100 characters or fewer") .refine((s) => DISCOVER_SEARCH_RE.test(s), "search must not contain control characters") .optional() .describe('Optional substring filter applied to "list_objects" results.'), }); // --- sf_gql_aggregate ------------------------------------------------------- const aliasField = z .string() .optional() .describe("GraphQL alias for this aggregation's result key."); // NOTE (W-23336443): the `function` enums below are z.discriminatedUnion // DISCRIMINATORS — they are intentionally NOT wrapped in enumStripControlChars. // Wrapping would be pointless (not harmful): discrimination reads the RAW // `ctx.data.function` to pick a branch BEFORE that branch's preprocess runs, so // a strip could never change branch selection. And there is nothing to close: a // discriminator that matches no branch raises `invalid_union_discriminator`, // whose issue lists only the EXPECTED options and does NOT echo the received // value (verified, zod 3.25.76) — unlike a plain enum's `invalid_enum_value`, // which reflects `received` verbatim. The three plain enums that DO echo their // input (mode/operation/groupBy function) are the ones wrapped. See // enumStripControlChars in ./fields for the full mechanism. const aggregationSchema = z.discriminatedUnion("function", [ z.object({ function: z.enum(["count", "countDistinct"]), field: graphqlName('SObject field API name. Defaults to "Id" when omitted.').optional(), alias: aliasField, }), z.object({ function: z.enum(["sum", "avg", "min", "max"]), field: graphqlName("SObject field API name. Required for sum/avg/min/max."), alias: aliasField, }), ]); const groupByElementSchema = z.union([ graphqlName("SObject field API name to group by (flat, non-dotted)."), z.object({ field: graphqlName("SObject field API name (DateTime/Date field)."), // Plain z.union member (NOT a discriminatedUnion discriminator), so the // enumStripControlChars wrapper is safe here (W-23336443). function: enumStripControlChars(z.enum(GROUP_BY_FUNCTIONS)).describe( "Date bucketing function from UIAPI GroupByFunction enum.", ), }), ]); export const AGGREGATE_INPUT = z.object({ org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), object: graphqlName('SObject API name, e.g. "Account", "Order". Must be a valid GraphQL Name.'), groupBy: z .array(groupByElementSchema) .optional() .describe( "GroupBy elements. Each is either a plain field name string (renders as `{Field: {group: true}}`) or an object `{field, function}` for date-bucketed groupBy (renders as `{Field: {function: CALENDAR_MONTH}}`). Omit or pass `[]` for un-grouped aggregation; with no aggregations either, defaults to `count` over `Id` (FR-8.2).", ), aggregations: z .array(aggregationSchema) .optional() .describe( "List of aggregation functions to compute. Each entry projects under its alias (or default ``).", ), filter: jsonCoercible( z.union([z.record(z.unknown()), varPlaceholder(), jsonLiteralString(), quotedString()]), ) .optional() .describe( '_Filter applied to every aggregation. String leaves matching $varName promote to typed query variables. Pass a single "$varName" string to promote the WHOLE filter to one optional _Filter variable (bind it to null for "no filter → all buckets").', ), orderBy: jsonCoercible( z.union([ z.record(z.unknown()), z.array(z.record(z.unknown())), varPlaceholder(), jsonLiteralString(), quotedString(), ]), ) .optional() .describe( '_OrderBy. Singleton object preferred; arrays are collapsed to the first entry. String leaves matching $varName promote to typed query variables. Pass a single "$varName" string to promote the whole orderBy to one optional _OrderBy variable.', ), first: jsonCoercible( z.union([z.number().int().positive(), varPlaceholder(), intLiteralString(), quotedString()]), ) .optional() .describe( 'Connection page size (top-N pattern). No default — omit for all buckets. Pass a single "$varName" string to promote it to an optional Int variable.', ), operationName: graphqlName( "Override the GraphQL operation name. Defaults to Aggregate.", ).optional(), }); // --- sf_gql_raw ------------------------------------------------------------- export const RAW_INPUT = z.object({ org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), commands: z .array(z.string()) .min(1) .describe( "CLI-style commands applied in order to a transient session. Supported verbs:\n" + " select e.g. select uiapi/query/Case/edges/node/Subject/value\n" + " set [] = e.g. set uiapi/query/Case first=10 | set uiapi/query/Case where.Status=New\n" + " var $name [default] e.g. var $id uiapi/query/Case/@args/where/Id/eq (type inferred from path)\n" + 'Each command is tokenized on spaces and a value MUST NOT contain a space — quoting does not help once a token has started (key=\'a b\' still splits). A filter value that contains a space (e.g. "New York", "In Progress") cannot be expressed via set in v1; use sf_gql_list with a JSON filter, or pass the value through a variable bound with var.\n' + "Fails fast: a bad command aborts the whole call. Other CLI verbs (cd, drop, alias, optional, unset) are NOT supported in v1 — and the `optional` verb is unnecessary here: like every declarative tool, sf_gql_raw emits all selected record fields with the @optional directive automatically, so a field the running user lacks FLS for is omitted gracefully instead of failing the whole query.", ), // enumStripControlChars strips Cc/Cf from a bad `operation` before the SDK // reflects it verbatim in a rejection message (W-23336443); the wrapper // preserves the published enum + optional flag (undefined passes through). operation: enumStripControlChars(z.enum(["query", "mutation", "aggregate"]).optional()).describe( 'Operation root. "query" (default) → uiapi.query; "mutation" → mutation root; "aggregate" → uiapi.aggregate.', ), typeName: graphqlName( "Override the GraphQL operation name. Defaults to Raw.", ).optional(), }); // --- sf_gql_create ---------------------------------------------------------- export const CREATE_INPUT = z.object({ org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), object: graphqlName('SObject API name, e.g. "Account", "Order". Must be a valid GraphQL Name.'), returnFields: z .array(dottedGraphqlName("Scalar field API name to read back on the created Record.")) .optional() .describe( 'Scalar field API names to read back on the created Record. Defaults to ["Id"]. Each entry must be a valid field name (dot-paths allowed); invalid entries are rejected. Dot-paths like Owner.Name are unsupported in mutation results and are skipped with a warning.', ), inputVariable: z .string() .optional() .describe( 'Variable name (without "$") for the create input. Defaults to "input". Declared as $: CreateInput!.', ), operationName: graphqlName( "Override the GraphQL operation name. Defaults to Create.", ).optional(), }); // --- sf_gql_update ---------------------------------------------------------- export const UPDATE_INPUT = z.object({ org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), object: graphqlName('SObject API name, e.g. "Account", "Order". Must be a valid GraphQL Name.'), returnFields: z .array(dottedGraphqlName("Scalar field API name to read back on the updated Record.")) .optional() .describe( 'Scalar field API names to read back on the updated Record. Defaults to ["Id"]. Each entry must be a valid field name (dot-paths allowed); invalid entries are rejected. Dot-paths like Owner.Name are unsupported in mutation results and are skipped with a warning.', ), inputVariable: z .string() .optional() .describe( 'Variable name (without "$") for the update input. Defaults to "input". Declared as $: UpdateInput!.', ), operationName: graphqlName( "Override the GraphQL operation name. Defaults to Update.", ).optional(), }); // --- sf_gql_delete ---------------------------------------------------------- export const DELETE_INPUT = z.object({ org: z.string().describe("Org alias resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), object: graphqlName('SObject API name, e.g. "Account", "Order". Must be a valid GraphQL Name.'), inputVariable: z .string() .optional() .describe( 'Variable name (without "$") for the delete input. Defaults to "input". Declared as $: RecordDeleteInput! — the schema-wide delete input carrying the record Id, not an -specific type.', ), operationName: graphqlName( "Override the GraphQL operation name. Defaults to Delete.", ).optional(), }); // --- sf_gql_connect --------------------------------------------------------- export const CONNECT_INPUT = z.object({ org: orgAlias("Org alias or username resolved via local Salesforce CLI auth (~/.sf, ~/.sfdx)."), forceRefresh: z .boolean() .optional() .describe( "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.", ), });