/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { type GraphQLSchema } from "graphql"; import { type ToolOutput, type VariableInfo } from "./types.js"; import { generateTypes } from "../lib/codegen.js"; import { applyGlobalSchemaPolicies } from "../lib/optional-fields.js"; import { renderQuery } from "../lib/query-builder.js"; import { type QuerySession } from "../lib/session.js"; import { validateQuery } from "../lib/validator.js"; const SCHEMA_LEVEL_ERROR_MARKERS = ["must define one or more fields"]; // W-22818723: the declarative tools emit `@optional` on selected record fields // (those FLS can gate) for graceful degradation. Live UIAPI advertises the // directive, but the // minimal schemas used in tests (and any introspection that omits directive // defs) don't — graphql-js then raises `Unknown directive "@optional".`. That // is an artifact of our own default, not a defect in the user's query, so it is // filtered out of `warnings[]` exactly like the schema-level markers above. const OPTIONAL_DIRECTIVE_UNKNOWN_MARKER = 'Unknown directive "@optional"'; /** * Render → validate → codegen → assemble. Shared finalizer for every typed * intent function (`buildList`, `buildDetail`, …). * * Validation and codegen never throw — their failure modes surface as entries in * `warnings[]`: * - `Validation: ` — non-schema-level errors from `graphql-js validate()`. * - `Validation: schema check skipped ()` — `validate()` itself crashed. * - `Codegen: ` — `generateTypes()` threw; `types` becomes `// Type generation failed: `. * * `renderQuery`, however, CAN throw — by design. It runs before (outside) the * try/catch and enforces the W-23204027 render-layer fail-safe: if any emitted * GraphQL Name (operation/variable/field name, alias, type condition, directive * name) or argument key is not a valid GraphQL Name, it throws rather than emit * an injectable identifier. This fires only on a programmer error or a hostile * input that slipped past the per-builder guards (e.g. a `filter`/`orderBy` key — * `z.record(z.unknown())` with no charset). At the MCP boundary `runTool` * catches it and the message (`… is not a valid GraphQL Name`) is classified as * `UserInput`, so failing loud is safe there. Direct callers (e.g. an eval * harness) that bypass that boundary must be prepared for the throw. Do NOT move * `renderQuery` into the try below — that would swallow the fail-safe and let an * injected selection reach the output. * * Schema-level errors (e.g. "Input Object type X must define one or more fields" * raised by malformed UIAPI schemas, not by the user's query) are filtered out * per FR-9.2. * * `primingNote` (if provided) is prepended to `warnings[]` so callers can surface * the FR-13.3 lazy-prime notification without a separate channel. `extraWarnings` * are appended after schema-validation warnings — used by intent builders to * surface non-validator findings (e.g. malformed `$var` placeholders). */ export function buildOutput( session: QuerySession, schema: GraphQLSchema, primingNote?: string, extraWarnings: string[] = [], ): ToolOutput { // Apply the global schema policies (@optional on FLS-gateable record fields + // displayValue where exposed) before render/validate/codegen so all six // declarative tools share one policy and the CLI's manual `optional` verb is // unaffected (W-22818723). applyGlobalSchemaPolicies(session, schema); const query = renderQuery(session); const warnings: string[] = primingNote ? [primingNote] : []; try { const errors = validateQuery(schema, query); for (const err of errors) { if (SCHEMA_LEVEL_ERROR_MARKERS.some((m) => err.message.includes(m))) continue; if (err.message.includes(OPTIONAL_DIRECTIVE_UNKNOWN_MARKER)) continue; warnings.push(`Validation: ${err.message}`); } } catch (err) { const message = err instanceof Error ? err.message : String(err); warnings.push(`Validation: schema check skipped (${message})`); } let types: string; try { types = generateTypes(session, schema); } catch (err) { const message = err instanceof Error ? err.message : String(err); types = `// Type generation failed: ${message}`; warnings.push(`Codegen: ${message}`); } const variables: VariableInfo[] = session.variables.map((v) => ({ name: v.name, type: v.type, required: v.type.endsWith("!"), })); warnings.push(...extraWarnings); return { query, variables, types, warnings }; }