Files
zeekay 40d120dbf7 refactor(api): say /v1/ in the Python callers
Every first-party Python caller and test now names the mount the API actually
answers on. Mechanical: URL literals only, and only where the /api/ was ours --
a preceding word character means it was a module path like insights/api/ or a
third-party host like slack.com/api/, and those are untouched.

Left saying /api/, each for a reason rather than for safety:
- products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints
- services/llm-gateway deploys separately and is under a code freeze, so moving
  its two calls would break it during the skew
- the mcp_store and Slack/Teams redirect_uris are registered with third parties
  and must byte-match what was registered
- the Wizard and Raycast client-metadata paths are OAuth client_ids
- /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the
  ingress routes to the Rust fleets, held by released SDK versions

Test URLconfs moved with the requests that call them, and the Datastore
user-selection fixtures now carry the leading slash that request.path has.
2026-08-08 18:38:44 -07:00

2402 lines
104 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Generates MCP tool handlers from YAML definitions + OpenAPI schema.
*
* YAML defines which operations to expose (operationId + MCP config).
* OpenAPI provides paths, methods, param sources.
* Orval-generated Zod schemas (in src/generated/api.ts) provide input validation.
*
* Reads:
* - services/mcp/definitions/*.yaml — tool config (operationId, scopes, annotations, etc.)
* - frontend/tmp/openapi.json — API structure (paths, params, types)
*
* Produces:
* - src/tools/generated/<category>.ts — handlers composing Orval Zod schemas
* - src/tools/generated/index.ts — barrel merging all categories
* - schema/generated-tool-definitions.json — tool metadata
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as parseYaml } from 'yaml'
import { discoverDefinitions, isQueryWrappersConfig } from './lib/definitions.mjs'
import { type JsonSchemaRoot, generateZodFromSchemaRef, getEntryVarName } from './lib/json-schema-to-zod'
import {
type CategoryConfig,
CategoryConfigSchema,
type EnabledToolConfig,
type EnabledQueryWrapperToolConfig,
type QueryWrappersConfig,
QueryWrappersConfigSchema,
type ToolConfig,
} from './yaml-config-schema'
const MCP_ROOT = path.resolve(__dirname, '..')
const REPO_ROOT = path.resolve(MCP_ROOT, '../..')
const DEFINITIONS_DIR = path.resolve(MCP_ROOT, 'definitions')
const PRODUCTS_DIR = path.resolve(REPO_ROOT, 'products')
const GENERATED_DIR = path.resolve(MCP_ROOT, 'src/tools/generated')
const DEFINITIONS_JSON_PATH = path.resolve(MCP_ROOT, 'schema/generated-tool-definitions.json')
const ALL_DEFINITIONS_JSON_PATH = path.resolve(MCP_ROOT, 'schema/tool-definitions-all.json')
const TOOL_DEFINITIONS_PATH = path.resolve(MCP_ROOT, 'schema/tool-definitions.json')
const OPENAPI_PATH = path.resolve(REPO_ROOT, 'frontend/tmp/openapi.json')
const SCHEMA_JSON_PATH = path.resolve(REPO_ROOT, 'frontend/src/queries/schema.json')
interface OpenApiParam {
in: 'path' | 'query' | 'header' | 'cookie'
name: string
required?: boolean
description?: string
schema: OpenApiSchema
/**
* Set by the Django factories in insights/api/openapi_parameters.py for
* params whose wire format is a URL-encoded JSON string but whose semantic
* shape is an object (e.g. variables_override / filters_override). The
* codegen widens these into z.union([z.string(), z.record(...)]).
*/
'x-accepts-stringified-json'?: boolean
/**
* OpenAPI serialization hints. drf-spectacular emits `style: form` +
* `explode: false` for django-filter comma-separated filters (BaseInFilter),
* which expect `?type=a,b` on the wire.
*/
style?: string
explode?: boolean
}
interface OpenApiSchema {
type?: string
format?: string
description?: string
nullable?: boolean
readOnly?: boolean
writeOnly?: boolean
items?: OpenApiSchema | { $ref: string }
properties?: Record<string, OpenApiSchema>
required?: string[]
$ref?: string
allOf?: Array<OpenApiSchema | { $ref: string }>
anyOf?: Array<OpenApiSchema | { $ref: string }>
oneOf?: Array<OpenApiSchema | { $ref: string }>
enum?: string[]
maxLength?: number
default?: unknown
}
interface OpenApiOperation {
operationId: string
parameters?: OpenApiParam[]
requestBody?: {
content?: {
'application/json'?: { schema: OpenApiSchema | { $ref: string } }
}
}
responses?: Record<
string,
{
content?: {
'application/json'?: { schema: OpenApiSchema | { $ref: string } }
}
}
>
summary?: string
description?: string
}
interface OpenApiSpec {
paths: Record<string, Record<string, OpenApiOperation>>
components?: { schemas?: Record<string, OpenApiSchema> }
}
interface ResolvedOperation {
method: string
path: string
operation: OpenApiOperation
}
// ------------------------------------------------------------------
// General helpers
// ------------------------------------------------------------------
function sortKeys<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))) as T
}
// ------------------------------------------------------------------
// OpenAPI helpers
// ------------------------------------------------------------------
function loadOpenApi(): OpenApiSpec {
if (!fs.existsSync(OPENAPI_PATH)) {
console.error(`OpenAPI schema not found at ${OPENAPI_PATH}. Run \`insightscli build:openapi-schema\` first.`)
process.exit(1)
}
const spec = JSON.parse(fs.readFileSync(OPENAPI_PATH, 'utf-8')) as OpenApiSpec
inlineParameterRefs(spec)
return spec
}
/**
* Replace ``{$ref: '#/components/parameters/...'}`` entries in operation parameters with the
* fully resolved parameter object. The rest of this script reads ``parameter.in`` /
* ``parameter.name`` directly, which would silently return ``undefined`` for ref-only entries
* and cause downstream tools to mis-handle path/query params (e.g. dropping the
* ``project_id`` / ``organization_id`` auto-resolved omit).
*/
function inlineParameterRefs(spec: OpenApiSpec): void {
const parameterComponents = (spec.components as { parameters?: Record<string, unknown> } | undefined)?.parameters
if (!parameterComponents) {
return
}
for (const methods of Object.values(spec.paths ?? {})) {
for (const op of Object.values(methods ?? {}) as Array<{ parameters?: unknown[] }>) {
if (!op?.parameters || !Array.isArray(op.parameters)) {
continue
}
op.parameters = op.parameters.map((param) => {
if (param && typeof param === 'object' && '$ref' in param && typeof param.$ref === 'string') {
const name = param.$ref.replace('#/components/parameters/', '')
const resolved = parameterComponents[name]
if (resolved) {
return resolved
}
}
return param
})
}
}
}
/**
* Collect schema type names from the OpenAPI spec's components.schemas.
* Used to validate that a resolved response type actually exists before emitting it.
*/
function loadKnownSchemaTypes(spec: OpenApiSpec): Set<string> {
return new Set(Object.keys(spec.components?.schemas ?? {}))
}
/**
* Find an operation by operationId. When the same endpoint exists at both
* /v1/environments/ and /v1/projects/, prefers /v1/projects/.
* Prefers an exact operationId match, then falls back to matching _N deduplicated
* variants (e.g. issues_list matches issues_list_2) for backward compatibility.
*/
function findOperation(spec: OpenApiSpec, operationId: string): ResolvedOperation | undefined {
const base = operationId.replace(/_\d+$/, '')
let exactFallback: ResolvedOperation | undefined
let baseFallback: ResolvedOperation | undefined
let baseProject: ResolvedOperation | undefined
for (const [urlPath, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
if (!op?.operationId) {
continue
}
const resolved = {
method: method.toUpperCase(),
path: urlPath,
operation: op,
}
if (op.operationId === operationId) {
if (urlPath.startsWith('/v1/projects/')) {
return resolved
}
if (!exactFallback) {
exactFallback = resolved
}
continue
}
const opBase = op.operationId.replace(/_\d+$/, '')
if (opBase !== base) {
continue
}
if (urlPath.startsWith('/v1/projects/')) {
if (!baseProject) {
baseProject = resolved
}
continue
}
if (!baseFallback) {
baseFallback = resolved
}
}
}
return exactFallback ?? baseProject ?? baseFallback
}
function resolveSchema(spec: OpenApiSpec, schemaOrRef: OpenApiSchema | { $ref: string }): OpenApiSchema | undefined {
if ('$ref' in schemaOrRef && schemaOrRef.$ref) {
const schemaName = schemaOrRef.$ref.replace('#/components/schemas/', '')
return spec.components?.schemas?.[schemaName]
}
return schemaOrRef as OpenApiSchema
}
const CSV_SCALAR_ITEM_TYPES = new Set(['string', 'number', 'integer', 'boolean'])
/**
* django-filter comma-separated filters (BaseInFilter) are emitted by
* drf-spectacular as array query params with `style: form` + `explode: false`,
* meaning `?type=a,b` on the wire. ApiClient.request() JSON-stringifies arrays
* for json.loads()-style backends, which DRF would read as one literal value
* (`type IN ('["a","b"]')`) and silently match nothing — so the handler must
* comma-join these before they reach the client. Only scalar-item arrays
* qualify; object/$ref-item arrays stay on the JSON path.
*/
function isCommaSeparatedQueryParam(p: OpenApiParam): boolean {
if (p.explode !== false || (p.style ?? 'form') !== 'form' || p['x-accepts-stringified-json']) {
return false
}
if (p.schema?.type !== 'array' || !p.schema.items) {
return false
}
const items = p.schema.items
if ('$ref' in items && items.$ref) {
return false
}
const itemType = (items as OpenApiSchema).type
return typeof itemType === 'string' && CSV_SCALAR_ITEM_TYPES.has(itemType)
}
/**
* Flatten body schema properties across the three composition keywords that
* can hide fields from a naive ``schema.properties`` read:
*
* - ``properties``: own fields on the schema
* - ``allOf``: composition / inheritance — every member must hold, so fields
* are merged and required sets are unioned
* - ``anyOf`` / ``oneOf``: discriminated unions (e.g. polymorphic Python
* serializers) — at least one variant must hold, so fields are merged
* while required is the intersection across variants
*
* Returns:
* - ``properties``: the union of properties across all branches
* - ``required``: fields that are required on every reachable branch
* - ``variantSpecific``: fields only some union variants declare — the handler
* must guard access with ``'X' in params`` to satisfy TypeScript narrowing
* on the resulting Zod union type
*
* Without this flattening, the generated handler can silently omit fields and
* POST an empty (or partial) body — the same class of bug that exists when
* ``anyOf`` / ``oneOf`` is unhandled.
*/
function flattenBodySchemaProperties(
spec: OpenApiSpec,
schema: OpenApiSchema | undefined
): { properties: Map<string, OpenApiSchema>; required: Set<string>; variantSpecific: Set<string> } {
if (!schema) {
return { properties: new Map(), required: new Set(), variantSpecific: new Set() }
}
// Start with own properties, then fold every allOf member in. Fields are
// merged (first writer wins on collision); required sets are unioned.
const baseProperties = new Map<string, OpenApiSchema>(Object.entries(schema.properties ?? {}))
const baseRequired = new Set<string>(schema.required ?? [])
for (const member of schema.allOf ?? []) {
const sub = flattenBodySchemaProperties(spec, resolveSchema(spec, member))
for (const [name, prop] of sub.properties) {
if (!baseProperties.has(name)) {
baseProperties.set(name, prop)
}
}
for (const r of sub.required) {
baseRequired.add(r)
}
}
const variantRefs = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])]
if (variantRefs.length === 0) {
return { properties: baseProperties, required: baseRequired, variantSpecific: new Set() }
}
const variants = variantRefs
.map((v) => resolveSchema(spec, v))
.filter((v): v is OpenApiSchema => !!v)
.map((v) => flattenBodySchemaProperties(spec, v))
const merged = new Map(baseProperties)
for (const v of variants) {
for (const [name, prop] of v.properties) {
if (!merged.has(name)) {
merged.set(name, prop)
}
}
}
const required = new Set(baseRequired)
const variantSpecific = new Set<string>()
for (const name of merged.keys()) {
// Locally-declared / allOf-composed fields are always present, so they
// are never variant-specific regardless of what each variant says.
if (baseProperties.has(name)) {
continue
}
if (variants.every((v) => v.properties.has(name))) {
if (variants.every((v) => v.required.has(name))) {
required.add(name)
}
} else {
variantSpecific.add(name)
}
}
return { properties: merged, required, variantSpecific }
}
/**
* Resolve the response type name from an operation's success response.
* Returns the Schemas.* type name if the $ref maps to a type that exists
* in generated.ts, undefined otherwise.
*/
function resolveResponseType(operation: OpenApiOperation, knownTypes: Set<string>): string | undefined {
for (const status of ['200', '201']) {
const responseContent = operation.responses?.[status]?.content?.['application/json']
if (!responseContent?.schema) {
continue
}
const schema = responseContent.schema as Record<string, unknown>
if ('$ref' in schema && schema.$ref) {
const schemaName = (schema.$ref as string).replace('#/components/schemas/', '')
if (knownTypes.has(schemaName)) {
return `Schemas.${schemaName}`
}
}
// Handle array responses (e.g. list endpoints with pagination_class = None)
const items = schema.items as Record<string, unknown> | undefined
if (schema.type === 'array' && items && '$ref' in items && items.$ref) {
const schemaName = (items.$ref as string).replace('#/components/schemas/', '')
if (knownTypes.has(schemaName)) {
return `Schemas.${schemaName}[]`
}
}
}
return undefined
}
// ------------------------------------------------------------------
// String helpers
// ------------------------------------------------------------------
function toPascalCase(str: string): string {
return str
.split('-')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join('')
}
function toCamelCase(str: string): string {
const pascal = toPascalCase(str)
return pascal.charAt(0).toLowerCase() + pascal.slice(1)
}
/**
* Escape a description string for embedding inside a single-quoted Zod
* `.describe('...')` call. Backslashes first, then single quotes, then collapse
* line breaks to spaces. Order matters — escape the escape character before
* anything else.
*/
function escapeForDescribe(desc: string): string {
return desc.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n\s*/g, ' ')
}
/**
* Parse enrich_url template into prefix, field, and source.
* '{id}' → { prefix: '', field: 'id', source: 'result' }
* 'script-{id}' → { prefix: 'script-', field: 'id', source: 'result' }
* '{params.id}' → { prefix: '', field: 'id', source: 'params' }
*
* Use '{params.x}' when the response has no usable identifier (e.g. action endpoints
* that return {results: [...]} with no top-level id) — the URL is built from the
* request params instead of the response body.
*/
function parseEnrichUrl(enrichUrl: string): {
prefix: string
field: string
suffix: string
source: 'result' | 'params'
} {
const match = enrichUrl.match(/^(.*?)\{(?:(params)\.)?(\w+)\}(.*)$/)
if (!match) {
throw new Error(`Invalid enrich_url format: ${enrichUrl}`)
}
return {
prefix: match[1]!,
field: match[3]!,
suffix: match[4] ?? '',
source: match[2] === 'params' ? 'params' : 'result',
}
}
/** Convert operationId (snake_case) to PascalCase for Orval schema names */
function operationIdToPascal(operationId: string): string {
return operationId
.split(/[_.]/)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join('')
}
// ------------------------------------------------------------------
// Schema composition — determine Orval imports and build expressions
// ------------------------------------------------------------------
interface SchemaComposition {
orvalImports: string[]
toolInputsImports: string[]
/** Names of helpers imported from `@/tools/cast-helpers` (e.g. `castStringToInt`). */
castHelperImports: Set<string>
/** Inline Zod declarations generated from schema_ref (emitted before the schema declaration) */
schemaRefBlocks: string[]
schemaExpr: string
pathParamNames: string[]
queryParamNames: string[]
/**
* Query params that must be comma-joined on the wire (OpenAPI
* `explode: false`, i.e. django-filter comma-separated filters) instead of
* JSON-stringified by ApiClient.request().
*/
csvQueryParamNames: Set<string>
bodyFieldNames: string[]
/**
* Body fields that only appear in some variants of a union (anyOf/oneOf) body schema.
* The handler emits `'X' in params && params.X !== undefined` for these to satisfy
* TypeScript's union narrowing — referencing `params.X` directly fails type-checking
* because variant types that lack the field have no such property.
*/
variantSpecificBodyFieldNames: Set<string>
/** Maps alias → original field name for renamed params */
renamedFields: Record<string, string>
/** Maps param name → fallback key for optional params with state fallbacks */
paramFallbacks: Record<string, string>
/**
* Maps canonical param name → accepted alias keys, from `param_overrides.<param>.aliases`.
* `generateToolCode` wraps the composed schema with
* `z.preprocess(normalizeParamAliases(...), ...)` when non-empty.
*/
paramAliases: Record<string, string[]>
}
function composeToolSchema(
config: ToolConfig,
resolved: ResolvedOperation,
spec: OpenApiSpec,
getQuerySchema: () => JsonSchemaRoot
): SchemaComposition {
const pascal = operationIdToPascal(config.operation)
const orvalImports: string[] = []
const schemaParts: string[] = []
const pathParamNames: string[] = []
const queryParamNames: string[] = []
const csvQueryParamNames = new Set<string>()
const bodyFieldNames: string[] = []
const variantSpecificBodyFieldNames = new Set<string>()
/**
* Params whose underlying Orval shape is `.optional()` (or `.nullish()`).
* Used by the cast branch in `param_overrides`: `z.preprocess(fn, source)`
* does not propagate optionality to the JSON Schema output, so we re-apply
* `.optional()` after the wrapper for fields that were optional at source.
* Path params are omitted (always required).
*/
const optionalParamNames = new Set<string>()
const excludeSet = new Set(config.exclude_params ?? [])
const includeSet = config.include_params ? new Set(config.include_params) : undefined
// original → alias mapping from rename_params config
const renameMap = new Map(Object.entries(config.rename_params ?? {}))
// Path params (omit project_id and organization_id — these are auto-resolved)
const allPathParams = (resolved.operation.parameters ?? []).filter((p) => p.in === 'path')
const autoResolvedParams = ['project_id', 'organization_id']
const pathParams = allPathParams.filter((p) => !autoResolvedParams.includes(p.name))
if (pathParams.length > 0) {
const importName = `${pascal}Params`
orvalImports.push(importName)
const omitKeys = autoResolvedParams.filter((k) => allPathParams.some((p) => p.name === k))
if (omitKeys.length > 0) {
const omitObj = omitKeys.map((k) => `${k}: true`).join(', ')
schemaParts.push(`${importName}.omit({ ${omitObj} })`)
} else {
schemaParts.push(importName)
}
for (const p of pathParams) {
pathParamNames.push(p.name)
}
}
// Query params (always omit format)
const queryParams = (resolved.operation.parameters ?? []).filter((p) => p.in === 'query' && p.name !== 'format')
if (queryParams.length > 0) {
// Filter by include/exclude
const usefulQueryParams = queryParams.filter((p) => {
if (excludeSet.has(p.name)) {
return false
}
if (includeSet) {
return includeSet.has(p.name)
}
return true
})
if (usefulQueryParams.length > 0) {
const importName = `${pascal}QueryParams`
orvalImports.push(importName)
// Build omit set: format (if present) + any excluded query params
const omitKeys: string[] = []
const allQueryParamNames = new Set(
(resolved.operation.parameters ?? []).filter((p) => p.in === 'query').map((p) => p.name)
)
if (allQueryParamNames.has('format')) {
omitKeys.push('format')
}
for (const p of queryParams) {
if (!usefulQueryParams.some((u) => u.name === p.name)) {
omitKeys.push(p.name)
}
}
if (omitKeys.length > 0) {
const omitObj = omitKeys.map((k) => `'${k}': true`).join(', ')
schemaParts.push(`${importName}.omit({ ${omitObj} })`)
} else {
schemaParts.push(importName)
}
for (const p of usefulQueryParams) {
queryParamNames.push(p.name)
// param_overrides with input_schema / schema_ref / cast replace or
// reshape the param type, so the array-join can't be assumed.
const override = config.param_overrides?.[p.name]
const schemaReplaced = !!(override && (override.input_schema || override.schema_ref || override.cast))
if (!schemaReplaced && isCommaSeparatedQueryParam(p)) {
csvQueryParamNames.add(p.name)
}
if (!p.required) {
optionalParamNames.add(p.name)
}
}
}
}
// Body (POST/PATCH/PUT)
if (['POST', 'PATCH', 'PUT'].includes(resolved.method)) {
const bodySchemaRef = resolved.operation.requestBody?.content?.['application/json']?.schema
if (bodySchemaRef) {
const importName = `${pascal}Body`
orvalImports.push(importName)
const bodyOmitFields = new Set<string>()
const bodySchema = resolveSchema(spec, bodySchemaRef)
// PATCH bodies are partial — Orval emits every field as `.optional()`.
// For POST/PUT, optionality follows the body schema's `required` list.
const {
properties: bodyProperties,
required: bodyRequiredSet,
variantSpecific: bodyVariantSpecific,
} = flattenBodySchemaProperties(spec, bodySchema)
const bodyAllOptional = resolved.method === 'PATCH'
for (const [name, prop] of bodyProperties) {
// Orval excludes readOnly fields from Body schemas — skip them
// so we don't try to .omit() keys that don't exist
if (prop.readOnly) {
continue
}
// exclude_params are removed at the Orval schema level by
// applyNestedExclusions in generate-orval-schemas.mjs, so
// they won't exist in the Zod schema. Skip them here to
// avoid generating .omit() calls for nonexistent fields.
if (excludeSet.has(name)) {
continue
}
// Auto-exclude underscore-prefixed fields
if (name.startsWith('_')) {
bodyOmitFields.add(name)
continue
}
if (includeSet && !includeSet.has(name)) {
bodyOmitFields.add(name)
continue
}
// If this field is renamed, store the alias instead so the
// handler references params.<alias>. The original→alias
// mapping is tracked in renamedFields for body-building.
const alias = renameMap.get(name)
const fieldKey = alias ?? name
bodyFieldNames.push(fieldKey)
if (bodyVariantSpecific.has(name)) {
variantSpecificBodyFieldNames.add(fieldKey)
}
if (bodyAllOptional || !bodyRequiredSet.has(name)) {
optionalParamNames.add(fieldKey)
}
}
if (bodyOmitFields.size > 0) {
const omitObj = [...bodyOmitFields].map((f) => `'${f}': true`).join(', ')
schemaParts.push(`${importName}.omit({ ${omitObj} })`)
} else {
schemaParts.push(importName)
}
}
}
// Compose schema expression
let schemaExpr: string
if (schemaParts.length === 0) {
schemaExpr = 'z.object({})'
} else if (schemaParts.length === 1) {
schemaExpr = schemaParts[0]!
} else {
schemaExpr = schemaParts[0]!
for (let i = 1; i < schemaParts.length; i++) {
schemaExpr += `.extend(${schemaParts[i]}.shape)`
}
}
// param_overrides:
// - input_schema → replace the field with a named import from @/schema/tool-inputs
// - schema_ref → generate inline Zod from schema.json and use that
// - description → wrap the existing Orval-derived field with .describe(...)
// - optional+fallback → make param optional and resolve from state when omitted
// - cast → wrap with z.preprocess(...) from @/tools/cast-helpers
// - aliases → collected here; generateToolCode wraps the finished schema
// with z.preprocess(normalizeParamAliases(...), ...)
const toolInputsImports: string[] = []
const castHelperImports = new Set<string>()
const schemaRefBlocks: string[] = []
const paramFallbacks: Record<string, string> = {}
const paramAliases: Record<string, string[]> = {}
// Fields added via param_overrides (input_schema/schema_ref) need to participate in
// the body builder for write ops — otherwise the override is in the schema but
// the handler never forwards the value to the API. On PATCH (partial update) the
// field defaults to optional, mirroring the original Orval body schema.
const isWriteOp = ['POST', 'PATCH', 'PUT'].includes(resolved.method)
const isPartialUpdate = resolved.method === 'PATCH'
const optionalSuffix = isPartialUpdate ? '.optional()' : ''
if (config.param_overrides) {
const schemaOverrides: string[] = []
for (const [paramName, override] of Object.entries(config.param_overrides)) {
// Track optional params with state fallbacks
if (override.optional && override.fallback) {
paramFallbacks[paramName] = override.fallback
}
if (override.aliases?.length) {
paramAliases[paramName] = override.aliases
}
// An `optional` override must also surface as optional in the agent-facing
// JSON Schema. When combined with `cast` the field is wrapped in
// `z.preprocess(...)`, which doesn't propagate inner `.optional()` (zod 4
// marks it required), so register it here for `wrapWithCast` to re-apply.
if (override.optional) {
optionalParamNames.add(paramName)
}
const castHelper = override.cast === 'string-int' ? 'castStringToInt' : null
if (castHelper) {
castHelperImports.add(castHelper)
}
// `z.preprocess(fn, source)` does not propagate inner `.optional()` to
// the JSON Schema output (zod 4 marks the wrapped field as required).
// Re-apply `.optional()` after the preprocess for fields whose source
// was optional, so the agent-facing tool schema stays accurate.
const wrapWithCast = (inner: string): string => {
if (!castHelper) {
return inner
}
const wrapped = `z.preprocess(${castHelper}, ${inner})`
return optionalParamNames.has(paramName) ? `${wrapped}.optional()` : wrapped
}
if (override.input_schema) {
toolInputsImports.push(override.input_schema)
schemaOverrides.push(`${paramName}: ${override.input_schema}${optionalSuffix}`)
if (isWriteOp && !bodyFieldNames.includes(paramName)) {
bodyFieldNames.push(paramName)
}
} else if (override.schema_ref) {
const excludeProps = override.exclude_properties ?? []
const zodCode = generateZodFromSchemaRef(getQuerySchema(), override.schema_ref, excludeProps)
schemaRefBlocks.push(zodCode)
const varName = getEntryVarName(override.schema_ref)
schemaOverrides.push(`${paramName}: ${varName}${optionalSuffix}`)
if (isWriteOp && !bodyFieldNames.includes(paramName)) {
bodyFieldNames.push(paramName)
}
} else if (
override.description ||
override.default !== undefined ||
override.optional ||
override.required ||
castHelper
) {
// Locate the Orval source schema this param came from, so we can reference
// its original field type via .shape and wrap it with .describe(...) / .default(...) / .optional() / cast.
let sourceImport: string | null = null
if (bodyFieldNames.includes(paramName)) {
sourceImport = `${pascal}Body`
} else if (queryParamNames.includes(paramName)) {
sourceImport = `${pascal}QueryParams`
} else if (pathParamNames.includes(paramName)) {
sourceImport = `${pascal}Params`
}
if (sourceImport) {
let expr = `${sourceImport}.shape['${paramName}']`
if (override.required) {
// PATCH body fields are `.optional()` in the Orval shape; unwrap so the
// tool schema requires the field, matching the backend serializer.
expr += '.unwrap()'
optionalParamNames.delete(paramName)
}
if (override.default !== undefined) {
expr += `.default(${JSON.stringify(override.default)}).optional()`
}
if (override.description) {
expr += `.describe('${escapeForDescribe(override.description)}')`
}
if (override.optional) {
expr += '.optional()'
}
schemaOverrides.push(`${paramName}: ${wrapWithCast(expr)}`)
}
}
}
if (schemaOverrides.length > 0) {
schemaExpr = `(${schemaExpr}).extend({ ${schemaOverrides.join(', ')} })`
}
}
// rename_params: swap original field names for MCP-safe aliases in the schema.
// The handler maps back to the original name when building the request body.
const renamedFields: Record<string, string> = {}
if (renameMap.size > 0) {
// We need the Body import to reference .shape['original'] for the alias type
const bodyImport = `${pascal}Body`
for (const [original, alias] of renameMap) {
renamedFields[alias] = original
schemaExpr += `\n .omit({ '${original}': true })`
schemaExpr += `\n .extend({ ${alias}: ${bodyImport}.shape['${original}'] })`
}
}
// Query params marked `x-accepts-stringified-json` (set by Django factories
// in insights/api/openapi_parameters.py) are stringified-JSON-objects on the
// wire but conceptually objects. Widen the schema to accept either shape so
// LLM agents can pass the object literal they just read from a sibling
// tool's response, without a JSON.stringify round-trip that frequently
// breaks on escaping. ApiClient.request() in services/mcp/src/api/client.ts
// JSON-stringify-s object query params automatically, so the schema-side
// union is sufficient — no handler changes required. Skipped when the YAML
// config also defines a param_overrides entry for the field, so explicit
// YAML always wins.
const explicitOverrideKeys = new Set(Object.keys(config.param_overrides ?? {}))
const stringifiedJsonQueryParams = queryParams.filter(
(p) =>
p['x-accepts-stringified-json'] === true &&
// queryParamNames is the post-include/exclude set, so this drops
// params the YAML excluded via include_params / exclude_params.
queryParamNames.includes(p.name) &&
!explicitOverrideKeys.has(p.name)
)
if (stringifiedJsonQueryParams.length > 0) {
const overrideEntries = stringifiedJsonQueryParams.map((p) => {
let expr = 'z.union([z.string(), z.record(z.string(), z.unknown())]).optional()'
const desc = p.description ?? p.schema?.description
if (desc) {
expr += `.describe('${escapeForDescribe(desc)}')`
}
return `${p.name}: ${expr}`
})
schemaExpr = `(${schemaExpr}).extend({ ${overrideEntries.join(', ')} })`
}
return {
orvalImports,
toolInputsImports,
castHelperImports,
schemaRefBlocks,
schemaExpr,
pathParamNames,
queryParamNames,
csvQueryParamNames,
bodyFieldNames,
variantSpecificBodyFieldNames,
renamedFields,
paramFallbacks,
paramAliases,
}
}
// ------------------------------------------------------------------
// Code generation helpers
// ------------------------------------------------------------------
/** Extract path parameter names from a URL pattern (e.g., {id} from /v1/projects/{project_id}/actions/{id}/) */
function extractPathParams(urlPattern: string): string[] {
const autoResolved = new Set(['project_id', 'organization_id'])
const matches = urlPattern.match(/\{(\w+)\}/g) ?? []
return matches.map((m) => m.slice(1, -1)).filter((name) => !autoResolved.has(name))
}
/** Build a template literal expression for the API path, interpolating auto-resolved IDs and path params.
* All interpolated values are wrapped with encodeURIComponent() to prevent path traversal.
* Params in `localVarParams` use a bare variable name (no prefix) because they are resolved to local variables. */
function buildPathExpr(
urlPath: string,
pathParamNames: string[],
paramAccessPrefix = '',
localVarParams: Set<string> = new Set()
): string {
let pathExpr = `\`${urlPath.replace('{project_id}', '${encodeURIComponent(String(projectId))}').replace('{organization_id}', '${encodeURIComponent(String(orgId))}')}\``
for (const pn of pathParamNames) {
const prefix = localVarParams.has(pn) ? '' : paramAccessPrefix
pathExpr = pathExpr.replace(`{${pn}}`, `\${encodeURIComponent(String(${prefix}${pn}))}`)
}
return pathExpr
}
// ------------------------------------------------------------------
// Response filtering templates
// ------------------------------------------------------------------
function buildResponseFilter(config: ToolConfig): {
code: string
helperImport: 'pickResponseFields' | 'omitResponseFields' | null
} {
if (config.response?.include?.length) {
const paths = config.response?.include.map((f) => `'${f}'`).join(', ')
// `selectable` lets the agent pass `fields` to narrow the allowlist per call; the Zod
// `z.enum(...).min(1)` on the schema already constrains `fields` to a non-empty subset of
// `include`, so an absent `fields` falls back to the full allowlist (an empty array is
// rejected at validation) and no separate intersection is needed.
const pathsExpr = config.response?.selectable
? `params.fields?.length ? params.fields : [${paths}]`
: `[${paths}]`
if (config.list) {
return {
code: ` const filtered = { ...result, results: (result.results ?? []).map((item: any) => pickResponseFields(item, ${pathsExpr})) } as typeof result\n`,
helperImport: 'pickResponseFields',
}
}
return {
code: ` const filtered = pickResponseFields(result, ${pathsExpr}) as typeof result\n`,
helperImport: 'pickResponseFields',
}
}
if (config.response?.exclude?.length) {
const paths = config.response?.exclude.map((f) => `'${f}'`).join(', ')
if (config.list) {
return {
code: ` const filtered = { ...result, results: (result.results ?? []).map((item: any) => omitResponseFields(item, [${paths}])) } as typeof result\n`,
helperImport: 'omitResponseFields',
}
}
return {
code: ` const filtered = omitResponseFields(result, [${paths}]) as typeof result\n`,
helperImport: 'omitResponseFields',
}
}
return { code: '', helperImport: null }
}
/**
* When `response.selectable` is set, emit a `.extend({ fields: ... })` clause adding an optional
* `fields` request param constrained (via `z.enum`) to the `include` allowlist. Returns '' when the
* tool doesn't opt in, so the schema expression is left untouched. Throws when `selectable` is set
* without an `include` allowlist, since there would be nothing to constrain the `fields` param to.
*/
function buildSelectableFieldsExtension(config: ToolConfig): string {
if (!config.response?.selectable) {
return ''
}
const include = config.response?.include
if (!include?.length) {
// selectable has no allowlist to constrain the `fields` param against — fail codegen loudly
// rather than silently emitting a tool whose `selectable` flag does nothing.
throw new Error(
`response.selectable requires a non-empty response.include allowlist — add the fields the tool may return, or remove selectable`
)
}
const enumValues = include.map((f) => `'${f}'`).join(', ')
const description =
'Optional subset of response fields to return, each a dot-path from the allowlist. ' +
'Omit to return all fields. Request only the fields your task needs to keep responses small.'
return `.extend({ fields: z.array(z.enum([${enumValues}])).min(1).optional().describe(${JSON.stringify(description)}) })`
}
// ------------------------------------------------------------------
// Response enrichment templates
// ------------------------------------------------------------------
function buildEnrichment(config: ToolConfig, category: CategoryConfig, resultVar = 'result'): string {
const baseUrl = config.url_prefix ?? category.url_prefix
// agent_note wraps the final returned expression so the note rides the same object the
// agent reads — point-of-use guidance without growing the tool description.
const noteLiteral = config.agent_note ? JSON.stringify(config.agent_note) : null
const noted = (expr: string): string => (noteLiteral ? `withAgentNote(${expr}, ${noteLiteral})` : expr)
const informationalWrapper = config.response?.informational_wrapper
const wrapped = (expr: string): string => {
const notedExpression = noted(expr)
const purposeArgument = informationalWrapper?.purpose ? `, ${JSON.stringify(informationalWrapper.purpose)}` : ''
return informationalWrapper
? `withInformationalResponse(${notedExpression}, ${JSON.stringify(informationalWrapper.tag)}${purposeArgument})`
: notedExpression
}
// Joiner between url_prefix and the enrich_url prefix: append `/` for path-segment enrichments,
// but not when the template continues with a query string (e.g. '?tab=alerts&alert_id={id}')
// or fragment (e.g. '#runs').
const joinerFor = (prefix: string): string => (/^[?#]/.test(prefix) ? '' : '/')
if (config.list && config.enrich_url) {
const { prefix, field, suffix, source } = parseEnrichUrl(config.enrich_url)
// For list endpoints, 'params.x' is not meaningful (items come from the response
// array, not request params), so force 'result' source here.
if (source === 'params') {
throw new Error(
`enrich_url '{params.${field}}' is not supported on list tools — list items are enriched from the response array`
)
}
const joiner = joinerFor(prefix)
const enriched = [
`await withInsightsUrl(context, {`,
` ...${resultVar},`,
` results: await Promise.all((${resultVar}.results ?? []).map((item) => withInsightsUrl(context, item, \`${baseUrl}${joiner}${prefix}\${item.${field}}${suffix}\`))),`,
` }, '${baseUrl}')`,
].join('\n')
return ` return ${wrapped(enriched)}\n`
}
if (config.list) {
return ` return ${wrapped(`await withInsightsUrl(context, ${resultVar}, '${baseUrl}')`)}\n`
}
if (config.enrich_url) {
const { prefix, field, suffix, source } = parseEnrichUrl(config.enrich_url)
const sourceExpr = source === 'params' ? `params.${field}` : `${resultVar}.${field}`
const joiner = joinerFor(prefix)
return ` return ${wrapped(`await withInsightsUrl(context, ${resultVar}, \`${baseUrl}${joiner}${prefix}\${${sourceExpr}}${suffix}\`)`)}\n`
}
return ` return ${wrapped(resultVar)}\n`
}
// ------------------------------------------------------------------
// Code generation for a single tool
// ------------------------------------------------------------------
function generateToolCode(
toolName: string,
config: ToolConfig,
resolved: ResolvedOperation,
category: CategoryConfig,
spec: OpenApiSpec,
knownTypes: Set<string>,
getQuerySchema: () => JsonSchemaRoot
): {
code: string
orvalImports: string[]
toolInputsImports: string[]
castHelperImports: Set<string>
schemaRefBlocks: string[]
responseType: string | undefined
needsWithInsightsUrl: boolean
hasEnrichment: boolean
needsWithAgentNote: boolean
hasAgentNote: boolean
needsWithInformationalResponse: boolean
toolUtilsValueImports: Set<string>
} {
const schemaName = `${toPascalCase(toolName)}Schema`
const factoryName = toCamelCase(toolName)
// When input_schema is set, use the named export from tool-inputs instead of Orval
if (config.input_schema) {
return generateCustomSchemaToolCode(toolName, config, resolved, category, schemaName, factoryName, knownTypes)
}
const composition = composeToolSchema(config, resolved, spec, getQuerySchema)
let responseType = config.response_type ?? resolveResponseType(resolved.operation, knownTypes)
// Soft-delete overrides the HTTP method: use PATCH instead of DELETE.
// `true` sends { deleted: true }, a string value specifies the field name (e.g. "archived").
const isSoftDelete = config.soft_delete !== undefined && config.soft_delete !== false
// For soft-delete tools the original operation is DELETE (typically 204 no-content),
// but the actual request is a PATCH to the same URL. Resolve the response type from
// the PATCH operation so the generated code gets a real type instead of `unknown`.
if (!responseType && isSoftDelete) {
const patchOp = (spec.paths[resolved.path] as Record<string, OpenApiOperation> | undefined)?.['patch']
if (patchOp) {
responseType = resolveResponseType(patchOp, knownTypes)
}
}
let schemaExpr = composition.schemaExpr
// Add the `fields` selection param before any validator wrapping, while schemaExpr is still a
// ZodObject that supports `.extend`.
const selectableExtension = buildSelectableFieldsExtension(config)
if (selectableExtension) {
schemaExpr = `${schemaExpr}${selectableExtension}`
}
if (config.validators && config.validators.length > 0) {
for (const fn of config.validators) {
schemaExpr = `(${schemaExpr}).superRefine(${fn})`
composition.toolInputsImports.push(fn)
}
}
// `param_overrides.<param>.aliases` — normalize alias keys to the canonical
// param before validation. Outermost wrapper so the rename happens before any
// field schema or validator runs; zod 4 renders a preprocess as the wrapped
// schema in JSON Schema output, so the advertised schema is unchanged.
const aliasEntries = Object.entries(composition.paramAliases)
if (aliasEntries.length > 0) {
composition.castHelperImports.add('normalizeParamAliases')
const aliasMapLiteral = `{ ${aliasEntries
.map(([canonical, aliases]) => `${canonical}: [${aliases.map((a) => `'${a}'`).join(', ')}]`)
.join(', ')} }`
schemaExpr = `z.preprocess(normalizeParamAliases(${aliasMapLiteral}), ${schemaExpr})`
}
const schemaDecl = `const ${schemaName} = ${schemaExpr}`
const localVarParams = new Set(Object.keys(composition.paramFallbacks))
const pathExpr = buildPathExpr(resolved.path, composition.pathParamNames, 'params.', localVarParams)
// Determine which auto-resolved IDs this operation needs
const needsProjectId = resolved.path.includes('{project_id}')
const needsOrgId = resolved.path.includes('{organization_id}')
// Build handler body
let handlerBody = ''
if (needsOrgId) {
handlerBody += ` const orgId = await context.stateManager.getOrgID()\n`
}
if (needsProjectId) {
handlerBody += ` const projectId = await context.stateManager.getProjectId()\n`
}
// Resolve optional params with state fallbacks
const fallbackMethodMap: Record<string, string> = {
orgId: 'context.stateManager.getOrgID()',
projectId: 'context.stateManager.getProjectId()',
}
for (const [paramName, fallbackKey] of Object.entries(composition.paramFallbacks)) {
const method = fallbackMethodMap[fallbackKey]
if (method) {
handlerBody += ` const ${paramName} = params.${paramName} ?? await ${method}\n`
handlerBody += ` if (!${paramName}) {\n`
handlerBody += ` throw new Error('${paramName} is required. Provide it explicitly or set an active ${fallbackKey === 'orgId' ? 'organization' : 'project'} first.')\n`
handlerBody += ` }\n`
}
}
const softDeleteField = typeof config.soft_delete === 'string' ? config.soft_delete : 'deleted'
const injectBody = config.inject_body ?? {}
const injectBodyEntries = Object.entries(injectBody)
const hasInjectBody = !isSoftDelete && injectBodyEntries.length > 0
const hasBody = !isSoftDelete && (composition.bodyFieldNames.length > 0 || hasInjectBody)
const hasQuery = composition.queryParamNames.length > 0
if (hasBody) {
handlerBody += ` const body: Record<string, unknown> = {}\n`
for (const bf of composition.bodyFieldNames) {
// If the field was renamed, bf is the alias (used for params access)
// and bodyKey is the original name (used as the HTTP body key).
const bodyKey = composition.renamedFields[bf] ?? bf
// Variant-specific fields (only in some union variants) need an `in`
// narrowing guard — TypeScript rejects bare `params.X` access otherwise.
const guard = composition.variantSpecificBodyFieldNames.has(bf)
? `'${bf}' in params && params.${bf} !== undefined`
: `params.${bf} !== undefined`
handlerBody += ` if (${guard}) body[${JSON.stringify(bodyKey)}] = params.${bf}\n`
}
// inject_body: hardcoded values that always override caller-supplied params.
// Emitted last so they overwrite anything set above.
for (const [key, value] of injectBodyEntries) {
handlerBody += ` body[${JSON.stringify(key)}] = ${JSON.stringify(value)}\n`
}
}
const httpMethod = isSoftDelete ? 'PATCH' : resolved.method
handlerBody += ` const result = await context.api.request<${responseType ?? 'unknown'}>({\n`
handlerBody += ` method: '${httpMethod}',\n`
handlerBody += ` path: ${pathExpr},\n`
if (isSoftDelete) {
handlerBody += ` body: { ${softDeleteField}: true },\n`
} else if (hasBody) {
handlerBody += ` body,\n`
}
if (hasQuery) {
const queryAssignments = composition.queryParamNames
.map((qn) => {
// explode: false params are comma-joined here because
// ApiClient.request() JSON-stringifies raw arrays (the
// json.loads()-style contract), which DRF CSV filters can't parse.
// Callers may pass either the array shape or a single string, so
// only join when it's actually an array; an empty array is omitted.
if (composition.csvQueryParamNames.has(qn)) {
return ` ${qn}: Array.isArray(params.${qn}) ? params.${qn}.join(',') || undefined : params.${qn},`
}
return ` ${qn}: params.${qn},`
})
.join('\n')
handlerBody += ` query: {\n${queryAssignments}\n },\n`
}
handlerBody += ` })\n`
// Response filtering — pick/omit fields before enrichment
const responseFilter = buildResponseFilter(config)
if (responseFilter.code) {
// Warn if filtering might break enrich_url — only for response-sourced fields;
// '{params.x}' fields are read from the request params, so filtering can't break them.
if (config.enrich_url) {
const { field, source } = parseEnrichUrl(config.enrich_url)
if (source === 'result') {
if (config.response?.exclude?.includes(field)) {
console.warn(`Warning: tool "${toolName}" excludes response field "${field}" used by enrich_url`)
}
if (config.response?.include?.length && !config.response?.include.includes(field)) {
console.warn(
`Warning: tool "${toolName}" uses response_include without "${field}" needed by enrich_url`
)
}
}
}
}
handlerBody += responseFilter.code
// Response enrichment — adds _insightsUrl for "View in Insights" links
const enrichmentVar = responseFilter.code ? 'filtered' : 'result'
handlerBody += buildEnrichment(config, category, enrichmentVar)
// Compute the result type for the ToolBase generic parameter
let resultType: string
let needsWithInsightsUrl = false
const hasEnrichment = !!(config.list || config.enrich_url)
if (config.list && config.enrich_url) {
needsWithInsightsUrl = !!responseType
resultType = responseType ? `WithInsightsUrl<${responseType}>` : 'unknown'
} else if (config.enrich_url) {
needsWithInsightsUrl = !!responseType
resultType = responseType ? `WithInsightsUrl<${responseType}>` : 'unknown'
} else if (config.list) {
needsWithInsightsUrl = !!responseType
resultType = responseType ? `WithInsightsUrl<${responseType}>` : 'unknown'
} else {
resultType = responseType ?? 'unknown'
}
// agent_note wraps whatever the enrichment produced (or the bare result).
const hasAgentNote = !!config.agent_note
const needsWithAgentNote = hasAgentNote && !!responseType
if (needsWithAgentNote) {
resultType = `WithAgentNote<${resultType}>`
}
const needsWithInformationalResponse = !!config.response?.informational_wrapper
if (needsWithInformationalResponse) {
resultType = `WithInformationalResponse<${resultType}>`
}
const appKey = config.ui_app ?? null
const enrichUsesParams = !!config.enrich_url && parseEnrichUrl(config.enrich_url).source === 'params'
// `params` is only referenced when a dynamic body/query/path param reads from it; inject_body
// alone doesn't touch params, so don't count it here.
const paramsUsed =
composition.bodyFieldNames.length > 0 ||
hasQuery ||
composition.pathParamNames.length > 0 ||
enrichUsesParams ||
!!selectableExtension
const unusedParamsComment = paramsUsed ? '' : '// eslint-disable-next-line no-unused-vars\n'
// When `confirmed_action` is declared, emit TWO factories instead of
// one — `<name>-prepare` and `<name>-execute`. The prepare tool signs
// the validated args into a hash; the execute tool verifies the hash,
// requires the literal "confirm" arg, and then runs the original
// handler body with the verified args. See `src/tools/confirmed-action-runtime.ts`.
if (config.confirmed_action) {
const wrapped = buildConfirmedActionFactories({
toolName,
config,
schemaName,
schemaDecl,
originalHandlerBody: handlerBody,
resultType,
needsProjectId,
needsOrgId,
paramFallbacks: composition.paramFallbacks,
})
return {
code: wrapped.code,
orvalImports: composition.orvalImports,
toolInputsImports: composition.toolInputsImports,
castHelperImports: composition.castHelperImports,
schemaRefBlocks: composition.schemaRefBlocks,
responseType,
needsWithInsightsUrl,
hasEnrichment,
needsWithAgentNote,
hasAgentNote,
needsWithInformationalResponse,
toolUtilsValueImports: new Set(
[
responseFilter.helperImport,
config.response?.informational_wrapper && 'withInformationalResponse',
].filter((value): value is string => !!value)
),
}
}
const toolBody = `{
name: '${toolName}',
schema: ${schemaName},
${unusedParamsComment}handler: async (context: Context, params: z.infer<typeof ${schemaName}>) => {
${handlerBody} },
}`
const factoryBody = appKey ? `withUiApp('${appKey}', ${toolBody})` : `(${toolBody})`
const code = `
${schemaDecl}
const ${factoryName} = (): ToolBase<typeof ${schemaName}, ${resultType}> => ${factoryBody}
`
return {
code,
orvalImports: composition.orvalImports,
toolInputsImports: composition.toolInputsImports,
castHelperImports: composition.castHelperImports,
schemaRefBlocks: composition.schemaRefBlocks,
responseType,
needsWithInsightsUrl,
hasEnrichment,
needsWithAgentNote,
hasAgentNote,
needsWithInformationalResponse,
toolUtilsValueImports: new Set(
[responseFilter.helperImport, config.response?.informational_wrapper && 'withInformationalResponse'].filter(
(value): value is string => !!value
)
),
}
}
/**
* Emit prepare + execute factories for a tool that declares `confirmed_action`.
* Returns the combined `code` block — the two factories plus the strict
* confirmation schema used by `-execute`. The base schema is emitted exactly as the
* non-confirmed path does it, so the prepare variant reuses it directly.
*/
function buildConfirmedActionFactories(args: {
toolName: string
config: ToolConfig
schemaName: string
schemaDecl: string
originalHandlerBody: string
resultType: string
needsProjectId: boolean
needsOrgId: boolean
paramFallbacks: Record<string, string>
}): { code: string } {
const {
toolName,
config,
schemaName,
schemaDecl,
originalHandlerBody,
resultType,
needsProjectId,
needsOrgId,
paramFallbacks,
} = args
const baseFactory = toCamelCase(toolName)
const prepareName = `${toolName}-prepare`
const executeName = `${toolName}-execute`
const prepareFactory = `${baseFactory}Prepare`
const executeFactory = `${baseFactory}Execute`
const executeSchemaName = `${schemaName}Execute`
const actionLabel = config.confirmed_action?.action_label ?? config.title ?? toolName
const messageTemplate = config.confirmed_action?.message ?? `Confirm ${actionLabel}?`
// Execute accepts only the two framework fields. The action arguments
// were validated and signed at prepare time; accepting them again would
// advertise mutable inputs that the runtime intentionally discards.
// `confirmation` is z.string() not z.literal('confirm') on purpose: the
// runtime checks the value and refuses with a structured tool-call
// result + metric counter. A literal would raise a generic zod parse
// error before our guard runs, losing the metric and the
// user-targetted refusal text.
const executeSchemaDecl = `const ${executeSchemaName} = z.strictObject({
confirmation_hash: z.string().describe('The confirmation_hash returned by the matching -prepare tool. Pass it back verbatim.'),
confirmation: z.string().describe('The literal string "confirm", typed by the user in chat. Required to proceed.'),
})`
// Bound scope: the active project/org is signed at prepare time and
// re-checked at execute time so a confirmation can't be replayed against
// a different project after `switch-project`. Resolved into locally-named
// consts so the `-execute` variant never collides with the `projectId`
// the original handler body declares.
const scopeResolveLines: string[] = []
const scopeParts: string[] = []
if (needsOrgId) {
scopeResolveLines.push(` const __scopeOrgId = await context.stateManager.getOrgID()`)
scopeParts.push('orgId: String(__scopeOrgId)')
}
if (needsProjectId) {
scopeResolveLines.push(` const __scopeProjectId = await context.stateManager.getProjectId()`)
scopeParts.push('projectId: String(__scopeProjectId)')
}
const hasScope = scopeParts.length > 0
const scopeResolveBlock = hasScope ? scopeResolveLines.join('\n') + '\n' : ''
const prepareScopeField = hasScope ? ` boundScope: { ${scopeParts.join(', ')} },\n` : ''
const executeScopeField = hasScope ? ` expectedScope: { ${scopeParts.join(', ')} },\n` : ''
// The original handler body re-reads project/org from state to build the
// API path. In the execute variant that would be a second, independent
// read after the scope check — MCP handles batched requests concurrently,
// so a `switch-project` racing between the scope check and the path build
// could retarget the request at a different project than the one the
// confirmation was bound to. Alias the path IDs to the scope values we
// captured once (and verified against `expectedScope`) so the check and
// the request always agree.
let executeHandlerBody = originalHandlerBody
if (needsOrgId) {
executeHandlerBody = executeHandlerBody.replace(
' const orgId = await context.stateManager.getOrgID()\n',
' const orgId = __scopeOrgId\n'
)
}
if (needsProjectId) {
executeHandlerBody = executeHandlerBody.replace(
' const projectId = await context.stateManager.getProjectId()\n',
' const projectId = __scopeProjectId\n'
)
}
// Optional params with a state fallback (e.g. an omitted org/project `id`
// that defaults to the active one) are resolved to a concrete value BEFORE
// signing, so the confirmation is bound to the exact target the user saw.
// Otherwise the fallback would be re-read at execute time and a
// `switch-organization` / `switch-project` between prepare and execute
// could retarget the confirmed action at a different entity.
const fallbackMethodMap: Record<string, string> = {
orgId: 'context.stateManager.getOrgID()',
projectId: 'context.stateManager.getProjectId()',
}
let prepareFallbackBlock = ''
const resolvedFallbackNames: string[] = []
for (const [paramName, fallbackKey] of Object.entries(paramFallbacks)) {
const method = fallbackMethodMap[fallbackKey]
if (!method) {
continue
}
const entity = fallbackKey === 'orgId' ? 'organization' : 'project'
prepareFallbackBlock += ` const ${paramName} = params.${paramName} ?? await ${method}\n`
prepareFallbackBlock += ` if (!${paramName}) {\n`
prepareFallbackBlock += ` throw new Error('${paramName} is required. Provide it explicitly or set an active ${entity} first.')\n`
prepareFallbackBlock += ` }\n`
resolvedFallbackNames.push(paramName)
}
const prepareArgsExpr =
resolvedFallbackNames.length > 0 ? `{ ...params, ${resolvedFallbackNames.join(', ')} }` : 'params'
// Prepare handler: validate args via the base schema (already happens
// before our handler runs) and call into the runtime. Args are signed
// (with any state fallbacks resolved) — bound to user identity + purpose
// (+ active scope).
const prepareHandler = ` const __runtime = getConfirmedActionRuntime()
${scopeResolveBlock}${prepareFallbackBlock} return await prepareConfirmedAction(context, {
args: ${prepareArgsExpr},
purpose: ${JSON.stringify(toolName)},
actionLabel: ${JSON.stringify(actionLabel)},
messageTemplate: ${JSON.stringify(messageTemplate)},
codec: __runtime.codec,
stash: __runtime.stash,
${prepareScopeField} })
`
// Execute handler: guard, then re-run the original handler body with
// the signed, verified args under the same `params` name the generated
// request code expects.
const executeHandler = ` const __runtime = getConfirmedActionRuntime()
${scopeResolveBlock} const __guard = await executeConfirmedAction<z.infer<typeof ${schemaName}>>(context, {
incomingArgs: confirmationParams,
purpose: ${JSON.stringify(toolName)},
codec: __runtime.codec,
ledger: __runtime.ledger,
stash: __runtime.stash,
${executeScopeField} })
if (!__guard.ok) {
return __guard.result as never
}
const params = __guard.verifiedArgs
${executeHandlerBody}`
const prepareBody = `{
name: '${prepareName}',
schema: ${schemaName},
handler: async (context: Context, params: z.infer<typeof ${schemaName}>) => {
${prepareHandler} },
}`
const executeBody = `{
name: '${executeName}',
schema: ${executeSchemaName},
handler: async (context: Context, confirmationParams: z.infer<typeof ${executeSchemaName}>) => {
${executeHandler} },
}`
const code = `
${schemaDecl}
${executeSchemaDecl}
const ${prepareFactory} = (): ToolBase<typeof ${schemaName}, PrepareConfirmedActionResult> => (${prepareBody})
const ${executeFactory} = (): ToolBase<typeof ${executeSchemaName}, ${resultType}> => (${executeBody})
`
return { code }
}
function generateCustomSchemaToolCode(
toolName: string,
config: ToolConfig,
resolved: ResolvedOperation,
category: CategoryConfig,
schemaName: string,
factoryName: string,
knownTypes: Set<string>
): {
code: string
orvalImports: string[]
toolInputsImports: string[]
castHelperImports: Set<string>
schemaRefBlocks: string[]
responseType: string | undefined
needsWithInsightsUrl: boolean
hasEnrichment: boolean
needsWithAgentNote: boolean
hasAgentNote: boolean
needsWithInformationalResponse: boolean
toolUtilsValueImports: Set<string>
} {
const pathParamNames = extractPathParams(resolved.path)
const pathExpr = buildPathExpr(resolved.path, pathParamNames)
const useBody = ['POST', 'PATCH', 'PUT'].includes(resolved.method)
const responseType = config.response_type ?? resolveResponseType(resolved.operation, knownTypes)
const needsProjectId = resolved.path.includes('{project_id}')
const needsOrgId = resolved.path.includes('{organization_id}')
let handlerBody = ''
if (needsOrgId) {
handlerBody += ` const orgId = await context.stateManager.getOrgID()\n`
}
if (needsProjectId) {
handlerBody += ` const projectId = await context.stateManager.getProjectId()\n`
}
handlerBody += ` const parsedParams = ${schemaName}.parse(params)\n`
if (pathParamNames.length > 0) {
const destructured = pathParamNames.map((p) => `${p}, `).join('')
if (useBody) {
handlerBody += ` const { ${destructured}...body } = parsedParams\n`
} else {
handlerBody += ` const { ${destructured}...query } = parsedParams\n`
}
}
handlerBody += ` const result = await context.api.request<${responseType ?? 'unknown'}>({\n`
handlerBody += ` method: '${resolved.method}',\n`
handlerBody += ` path: ${pathExpr},\n`
if (pathParamNames.length > 0) {
if (useBody) {
handlerBody += ` body,\n`
} else {
handlerBody += ` query,\n`
}
} else if (useBody) {
handlerBody += ` body: parsedParams,\n`
} else {
handlerBody += ` query: parsedParams,\n`
}
handlerBody += ` })\n`
// Response filtering — pick/omit fields before enrichment
const responseFilter = buildResponseFilter(config)
handlerBody += responseFilter.code
const enrichmentVar = responseFilter.code ? 'filtered' : 'result'
handlerBody += buildEnrichment(config, category, enrichmentVar)
let baseSchemaExpr = config.input_schema as string
const toolInputsImports: string[] = config.input_schema ? [config.input_schema] : []
// Add the `fields` selection param before any validator wrapping, while the custom schema is
// still a ZodObject that supports `.extend` — mirrors the Orval-schema path in generateToolCode.
const selectableExtension = buildSelectableFieldsExtension(config)
if (selectableExtension) {
baseSchemaExpr = `${baseSchemaExpr}${selectableExtension}`
}
if (config.validators && config.validators.length > 0) {
for (const fn of config.validators) {
baseSchemaExpr = `(${baseSchemaExpr}).superRefine(${fn})`
toolInputsImports.push(fn)
}
}
const hasAgentNote = !!config.agent_note
const needsWithAgentNote = hasAgentNote && !!responseType
let customResultType = needsWithAgentNote ? `WithAgentNote<${responseType}>` : (responseType ?? 'unknown')
const needsWithInformationalResponse = !!config.response?.informational_wrapper
if (needsWithInformationalResponse) {
customResultType = `WithInformationalResponse<${customResultType}>`
}
const code = `
const ${schemaName} = ${baseSchemaExpr}
const ${factoryName} = (): ToolBase<typeof ${schemaName}, ${customResultType}> => ({
name: '${toolName}',
schema: ${schemaName},
handler: async (context: Context, params: z.infer<typeof ${schemaName}>) => {
${handlerBody} },
})
`
return {
code,
orvalImports: [],
toolInputsImports,
castHelperImports: new Set(),
schemaRefBlocks: [],
responseType,
needsWithInsightsUrl: false,
hasEnrichment: false,
needsWithAgentNote,
hasAgentNote,
needsWithInformationalResponse,
toolUtilsValueImports: new Set(
[responseFilter.helperImport, config.response?.informational_wrapper && 'withInformationalResponse'].filter(
(value): value is string => !!value
)
),
}
}
// ------------------------------------------------------------------
// Generate a full category file
// ------------------------------------------------------------------
function generateCategoryFile(
category: CategoryConfig,
fileName: string,
moduleName: string,
spec: OpenApiSpec,
knownTypes: Set<string>,
getQuerySchema: () => JsonSchemaRoot
): {
code: string
enabledTools: [string, EnabledToolConfig, ResolvedOperation][]
enabledWrappers: [string, EnabledQueryWrapperToolConfig][]
} {
const enabledTools: [string, EnabledToolConfig, ResolvedOperation][] = []
for (const [name, config] of Object.entries(category.tools)) {
if (!config.enabled) {
continue
}
if (!config.scopes?.length) {
console.error(`Enabled tool "${name}" is missing required "scopes"`)
process.exit(1)
}
if (!config.annotations) {
console.error(`Enabled tool "${name}" is missing required "annotations"`)
process.exit(1)
}
const resolved = findOperation(spec, config.operation)
if (!resolved) {
console.warn(
`Warning: operationId "${config.operation}" not found in OpenAPI for tool "${name}" — skipping`
)
continue
}
enabledTools.push([name, config as EnabledToolConfig, resolved])
}
// Collect enabled query wrappers from the optional wrappers section
const enabledWrappers: [string, EnabledQueryWrapperToolConfig][] = []
if (category.wrappers) {
const querySchema = getQuerySchema()
for (const [name, wrapperConfig] of Object.entries(category.wrappers)) {
if (!wrapperConfig.enabled) {
continue
}
if (!wrapperConfig.scopes?.length) {
console.error(`Enabled query wrapper "${name}" is missing required "scopes"`)
process.exit(1)
}
if (!wrapperConfig.annotations) {
console.error(`Enabled query wrapper "${name}" is missing required "annotations"`)
process.exit(1)
}
if (!querySchema.definitions[wrapperConfig.schema_ref]) {
console.error(
`Query wrapper "${name}": schema_ref "${wrapperConfig.schema_ref}" not found in schema.json`
)
process.exit(1)
}
enabledWrappers.push([name, wrapperConfig as EnabledQueryWrapperToolConfig])
}
}
const allOrvalImports = new Set<string>()
const allToolInputsImports = new Set<string>()
const allCastHelperImports = new Set<string>()
const allSchemaRefBlocks: string[] = []
const emittedSchemaRefDefs = new Set<string>()
const toolCodes: string[] = []
let hasResponseType = false
let hasWithInsightsUrl = false
let hasEnrichment = false
let hasWithAgentNote = false
let hasAgentNote = false
let hasWithInformationalResponse = false
const requiredToolUtilsValueImports = new Set<string>()
for (const [name, config, resolved] of enabledTools) {
const result = generateToolCode(name, config, resolved, category, spec, knownTypes, getQuerySchema)
toolCodes.push(result.code)
for (const imp of result.orvalImports) {
allOrvalImports.add(imp)
}
for (const imp of result.toolInputsImports) {
allToolInputsImports.add(imp)
}
for (const imp of result.castHelperImports) {
allCastHelperImports.add(imp)
}
// Collect schema_ref blocks, deduplicating by const name
for (const block of result.schemaRefBlocks) {
for (const decl of block.split('\n\nconst ')) {
const line = decl.startsWith('const ') ? decl : `const ${decl}`
const match = line.match(/^const (\w+) =/)
if (match && !emittedSchemaRefDefs.has(match[1]!)) {
emittedSchemaRefDefs.add(match[1]!)
allSchemaRefBlocks.push(line)
}
}
}
if (result.responseType) {
hasResponseType = true
}
if (result.needsWithInsightsUrl) {
hasWithInsightsUrl = true
}
if (result.hasEnrichment) {
hasEnrichment = true
}
if (result.needsWithAgentNote) {
hasWithAgentNote = true
}
if (result.hasAgentNote) {
hasAgentNote = true
}
if (result.needsWithInformationalResponse) {
hasWithInformationalResponse = true
}
for (const toolUtilsValueImport of result.toolUtilsValueImports) {
requiredToolUtilsValueImports.add(toolUtilsValueImport)
}
}
// Generate query wrapper Zod schemas and registrations if wrappers are present
let wrapperSchemasCode = ''
let wrapperMapEntries = ''
if (enabledWrappers.length > 0) {
const querySchema = getQuerySchema()
const allZodBlocks: string[] = []
const emittedDefs = new Set<string>()
// Track which properties each base schema actually has after deduplication,
// so per-tool .omit() calls only reference properties that exist.
const baseSchemaProps = new Map<string, Set<string>>()
for (const [, wrapperConfig] of enabledWrappers) {
const excludeProps = [...(wrapperConfig.exclude_properties ?? [])]
const zodCode = generateZodFromSchemaRef(querySchema, wrapperConfig.schema_ref, excludeProps)
const lines = zodCode.split('\n\nconst ')
for (let i = 0; i < lines.length; i++) {
const block = i === 0 ? lines[i]! : `const ${lines[i]}`
const match = block.match(/^const (\w+) =/)
if (match && !emittedDefs.has(match[1]!)) {
emittedDefs.add(match[1]!)
allZodBlocks.push(block)
// Record properties of the emitted base schema
const entryVarName = getEntryVarName(wrapperConfig.schema_ref)
if (match[1] === entryVarName) {
const propNames = new Set<string>()
for (const propMatch of block.matchAll(/^\s{4}(\w+):/gm)) {
propNames.add(propMatch[1]!)
}
baseSchemaProps.set(entryVarName, propNames)
}
}
}
}
// Generate per-tool schemas when the tool needs to customize the base schema
// via property_defaults or omitting exclude_properties that survived deduplication.
const perToolSchemaNames = new Map<string, string>()
for (const [name, wrapperConfig] of enabledWrappers) {
const hasDefaults =
wrapperConfig.property_defaults && Object.keys(wrapperConfig.property_defaults).length > 0
const baseVarName = getEntryVarName(wrapperConfig.schema_ref)
const baseProps = baseSchemaProps.get(baseVarName) ?? new Set()
const keysToOmit = new Set<string>()
for (const k of wrapperConfig.exclude_properties ?? []) {
if (baseProps.has(k)) {
keysToOmit.add(k)
}
}
const hasOmits = keysToOmit.size > 0
if (!hasDefaults && !hasOmits) {
continue
}
const toolSchemaName = `${toPascalCase(name)}Schema`
const omitExpr = hasOmits ? `.omit({ ${[...keysToOmit].map((k) => `${k}: true`).join(', ')} })` : ''
const overrides: string[] = []
for (const [prop, defaultValue] of Object.entries(wrapperConfig.property_defaults ?? {})) {
overrides.push(
` ${prop}: ${baseVarName}.shape.${prop}.default(${JSON.stringify(defaultValue)}).optional(),`
)
}
const extendExpr = overrides.length > 0 ? `.extend({\n${overrides.join('\n')}\n})` : ''
allZodBlocks.push(`const ${toolSchemaName} = ${baseVarName}${omitExpr}${extendExpr}`)
perToolSchemaNames.set(name, toolSchemaName)
}
wrapperSchemasCode =
'\n// --- Query wrapper schemas from schema.json ---\n\n' + allZodBlocks.join('\n\n') + '\n'
wrapperMapEntries = enabledWrappers
.map(([name, wrapperConfig]) => {
const schemaVarName = perToolSchemaNames.get(name) ?? getEntryVarName(wrapperConfig.schema_ref)
const kind = extractKindFromSchemaRef(querySchema, wrapperConfig.schema_ref)
const configParts = [`name: '${name}'`, `schema: ${schemaVarName}`, `kind: '${kind}'`]
if (wrapperConfig.ui_resource_uri) {
configParts.push(`uiResourceUri: '${wrapperConfig.ui_resource_uri}'`)
}
// Unlike the definitions-file branch, only an explicit `use_optimized_output` emits a
// format here — absent keeps the factory default, so pre-existing product wrappers
// don't silently change encoding.
if (wrapperConfig.use_optimized_output !== undefined) {
configParts.push(`outputFormat: '${wrapperConfig.use_optimized_output ? 'optimized' : 'json'}'`)
}
if (wrapperConfig.url_prefix) {
configParts.push(`urlPrefix: '${wrapperConfig.url_prefix}'`)
}
return ` '${name}': createQueryWrapper({ ${configParts.join(', ')} }),`
})
.join('\n')
}
const restMapEntries = enabledTools
.flatMap(([name, config]) => {
if (config.confirmed_action) {
return [
` '${name}-prepare': ${toCamelCase(name)}Prepare,`,
` '${name}-execute': ${toCamelCase(name)}Execute,`,
]
}
return [` '${name}': ${toCamelCase(name)},`]
})
.join('\n')
const hasConfirmedAction = enabledTools.some(([, c]) => c.confirmed_action)
const mapEntries = [restMapEntries, wrapperMapEntries].filter(Boolean).join('\n')
const orvalImportLine =
allOrvalImports.size > 0
? `\nimport { ${[...allOrvalImports].sort().join(', ')} } from '@/generated/${moduleName}/api'\n`
: ''
const schemasImportLine = hasResponseType ? `\nimport type { Schemas } from '@/api/generated'\n` : ''
const hasUiMeta = enabledTools.some(([, config]) => config.ui_app)
const withUiAppImportLine = hasUiMeta ? `import { withUiApp } from '@/resources/ui-apps'\n` : ''
const toolInputsImportLine =
allToolInputsImports.size > 0
? `import { ${[...allToolInputsImports].sort().join(', ')} } from '@/schema/tool-inputs'\n`
: ''
const castHelpersImportLine =
allCastHelperImports.size > 0
? `import { ${[...allCastHelperImports].sort().join(', ')} } from '@/tools/cast-helpers'\n`
: ''
// Build tool-utils import (WithInsightsUrl type + withInsightsUrl runtime helper)
const toolUtilsTypeImports: string[] = []
const toolUtilsValueImports: string[] = []
if (hasWithInsightsUrl) {
toolUtilsTypeImports.push('WithInsightsUrl')
}
if (hasWithAgentNote) {
toolUtilsTypeImports.push('WithAgentNote')
}
if (hasWithInformationalResponse) {
toolUtilsTypeImports.push('WithInformationalResponse')
}
if (hasEnrichment) {
toolUtilsValueImports.push('withInsightsUrl')
}
if (hasAgentNote) {
toolUtilsValueImports.push('withAgentNote')
}
for (const imp of requiredToolUtilsValueImports) {
toolUtilsValueImports.push(imp)
}
let toolUtilsImportLine = ''
if (toolUtilsValueImports.length > 0 && toolUtilsTypeImports.length > 0) {
toolUtilsImportLine = `import { ${toolUtilsValueImports.join(', ')}, type ${toolUtilsTypeImports.join(', type ')} } from '@/tools/tool-utils'\n`
} else if (toolUtilsValueImports.length > 0) {
toolUtilsImportLine = `import { ${toolUtilsValueImports.join(', ')} } from '@/tools/tool-utils'\n`
} else if (toolUtilsTypeImports.length > 0) {
toolUtilsImportLine = `import type { ${toolUtilsTypeImports.join(', ')} } from '@/tools/tool-utils'\n`
}
const wrapperImportLine =
enabledWrappers.length > 0 ? `import { createQueryWrapper } from '@/tools/query-wrapper-factory'\n` : ''
const confirmedActionImportLine = hasConfirmedAction
? `import { getConfirmedActionRuntime } from '@/tools/confirmed-action-registry'\nimport { executeConfirmedAction, prepareConfirmedAction, type PrepareConfirmedActionResult } from '@/tools/confirmed-action-runtime'\n`
: ''
const schemaRefCode = allSchemaRefBlocks.length > 0 ? '\n' + allSchemaRefBlocks.join('\n\n') + '\n' : ''
const code = `// AUTO-GENERATED from ${fileName} + OpenAPI — do not edit
import { z } from 'zod'
import type { Context, ToolBase, ZodObjectAny } from '@/tools/types'
${toolUtilsImportLine ? `${toolUtilsImportLine}` : ''}${schemasImportLine}${withUiAppImportLine}${toolInputsImportLine}${castHelpersImportLine}${wrapperImportLine}${confirmedActionImportLine}${orvalImportLine}${schemaRefCode}${toolCodes.join('')}${wrapperSchemasCode}
export const GENERATED_TOOLS: Record<string, () => ToolBase<ZodObjectAny>> = {
${mapEntries}
}
`
return { code, enabledTools, enabledWrappers }
}
// ------------------------------------------------------------------
// Generate tool definitions JSON
// ------------------------------------------------------------------
/**
* Resolve a tool description from either an inline `description` string or a
* `description_file` path (resolved relative to `yamlDir`). Returns the
* fallback when neither is set.
*/
function resolveDescription(
config: { description?: string | undefined; description_file?: string | undefined },
yamlDir: string,
fallback: string
): string {
if (config.description_file) {
const filePath = path.resolve(yamlDir, config.description_file)
if (!fs.existsSync(filePath)) {
console.error(`description_file not found: ${filePath}`)
process.exit(1)
}
return fs.readFileSync(filePath, 'utf-8').trim()
}
return config.description?.trim() || fallback
}
function generateDefinitionsJson(
categories: {
config: CategoryConfig
enabledTools: [string, EnabledToolConfig, ResolvedOperation][]
enabledWrappers: [string, EnabledQueryWrapperToolConfig][]
yamlDir: string
}[]
): Record<string, unknown> {
const definitions: Record<string, unknown> = {}
for (const { config: category, enabledTools, enabledWrappers, yamlDir } of categories) {
for (const [name, toolConfig, resolved] of enabledTools) {
const opDescription = resolved.operation.description?.trim() || resolved.operation.summary?.trim() || ''
const baseDescription = resolveDescription(toolConfig, yamlDir, opDescription)
const baseTitle = toolConfig.title || resolved.operation.summary || name
const baseSummary = toolConfig.title || opDescription.split('.')[0] || name
// Per-tool feature_flag wins; otherwise inherit the category-level
// gate (lets one line gate a whole not-yet-GA product).
const featureFlag = toolConfig.feature_flag ?? category.feature_flag
const featureEntitlement = toolConfig.feature_entitlement ?? category.feature_entitlement
const featureFlagBehavior = toolConfig.feature_flag_behavior ?? category.feature_flag_behavior
const featureFlagVariant = toolConfig.feature_flag_variant ?? category.feature_flag_variant
if (toolConfig.confirmed_action) {
// Two-tool typed-confirm paradigm: emit `<name>-prepare` and
// `<name>-execute` entries. Descriptions explicitly guide the
// model through the prepare → ask user → execute sequence.
const actionLabel = toolConfig.confirmed_action.action_label ?? baseTitle
definitions[`${name}-prepare`] = {
description:
`Step 1 of 2 for ${actionLabel}. ` +
`Validates the arguments and returns a signed confirmation_hash plus a message to surface to the user. ` +
`The user must reply with the literal word "confirm" before you call the matching -execute tool with the hash. ` +
`Original action: ${baseDescription}`,
category: category.category,
feature: category.feature,
summary: `${baseSummary} (prepare)`,
title: `${baseTitle} (prepare)`,
required_scopes: toolConfig.scopes,
annotations: {
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
readOnlyHint: true,
},
...(toolConfig.requires_ai_consent ? { requires_ai_consent: true } : {}),
...(featureFlag ? { feature_flag: featureFlag } : {}),
...(featureEntitlement ? { feature_entitlement: featureEntitlement } : {}),
...(featureFlagBehavior ? { feature_flag_behavior: featureFlagBehavior } : {}),
...(featureFlagVariant ? { feature_flag_variant: featureFlagVariant } : {}),
...(toolConfig.system_prompt_hint ? { system_prompt_hint: toolConfig.system_prompt_hint } : {}),
}
definitions[`${name}-execute`] = {
description:
`Step 2 of 2 for ${actionLabel}. ` +
`Verifies the confirmation_hash from -prepare and the literal "confirm" string typed by the user, then performs the action. ` +
`ONLY call this after the user has explicitly typed "confirm" in chat. ` +
`Original action: ${baseDescription}`,
category: category.category,
feature: category.feature,
summary: `${baseSummary} (execute)`,
title: `${baseTitle} (execute)`,
required_scopes: toolConfig.scopes,
annotations: {
destructiveHint: toolConfig.annotations.destructive,
idempotentHint: toolConfig.annotations.idempotent,
openWorldHint: true,
readOnlyHint: toolConfig.annotations.readOnly,
},
...(toolConfig.requires_ai_consent ? { requires_ai_consent: true } : {}),
...(featureFlag ? { feature_flag: featureFlag } : {}),
...(featureEntitlement ? { feature_entitlement: featureEntitlement } : {}),
...(featureFlagBehavior ? { feature_flag_behavior: featureFlagBehavior } : {}),
...(featureFlagVariant ? { feature_flag_variant: featureFlagVariant } : {}),
...(toolConfig.system_prompt_hint ? { system_prompt_hint: toolConfig.system_prompt_hint } : {}),
}
} else {
definitions[name] = {
description: baseDescription,
category: category.category,
feature: category.feature,
summary: baseSummary,
title: baseTitle,
required_scopes: toolConfig.scopes,
annotations: {
destructiveHint: toolConfig.annotations.destructive,
idempotentHint: toolConfig.annotations.idempotent,
openWorldHint: true,
readOnlyHint: toolConfig.annotations.readOnly,
},
...(toolConfig.requires_ai_consent ? { requires_ai_consent: true } : {}),
...(featureFlag ? { feature_flag: featureFlag } : {}),
...(featureEntitlement ? { feature_entitlement: featureEntitlement } : {}),
...(featureFlagBehavior ? { feature_flag_behavior: featureFlagBehavior } : {}),
...(featureFlagVariant ? { feature_flag_variant: featureFlagVariant } : {}),
...(toolConfig.system_prompt_hint ? { system_prompt_hint: toolConfig.system_prompt_hint } : {}),
}
}
}
// Include query wrappers defined in the same category file
for (const [name, wrapperConfig] of enabledWrappers) {
definitions[name] = {
description: resolveDescription(wrapperConfig, yamlDir, ''),
category: category.category,
feature: category.feature,
summary: wrapperConfig.title || name,
title: wrapperConfig.title || name,
required_scopes: wrapperConfig.scopes,
annotations: {
destructiveHint: wrapperConfig.annotations.destructive,
idempotentHint: wrapperConfig.annotations.idempotent,
openWorldHint: true,
readOnlyHint: wrapperConfig.annotations.readOnly,
},
...(wrapperConfig.feature_flag ? { feature_flag: wrapperConfig.feature_flag } : {}),
...(wrapperConfig.feature_entitlement
? { feature_entitlement: wrapperConfig.feature_entitlement }
: {}),
...(wrapperConfig.feature_flag_behavior
? { feature_flag_behavior: wrapperConfig.feature_flag_behavior }
: {}),
...(wrapperConfig.feature_flag_variant
? { feature_flag_variant: wrapperConfig.feature_flag_variant }
: {}),
...(wrapperConfig.system_prompt_hint ? { system_prompt_hint: wrapperConfig.system_prompt_hint } : {}),
}
}
}
return definitions
}
// ------------------------------------------------------------------
// Query wrapper generation — tools backed by schema.json definitions
// ------------------------------------------------------------------
function loadQuerySchema(): JsonSchemaRoot {
if (!fs.existsSync(SCHEMA_JSON_PATH)) {
console.error(`Query schema not found at ${SCHEMA_JSON_PATH}. Run schema:build:json first.`)
process.exit(1)
}
return JSON.parse(fs.readFileSync(SCHEMA_JSON_PATH, 'utf-8')) as JsonSchemaRoot
}
function generateQueryWrapperFile(
config: QueryWrappersConfig,
fileName: string,
querySchema: JsonSchemaRoot
): {
code: string
enabledWrappers: [string, EnabledQueryWrapperToolConfig][]
} {
const enabledWrappers: [string, EnabledQueryWrapperToolConfig][] = []
for (const [name, toolConfig] of Object.entries(config.wrappers)) {
if (!toolConfig.enabled) {
continue
}
if (!toolConfig.scopes?.length) {
console.error(`Enabled query wrapper "${name}" is missing required "scopes"`)
process.exit(1)
}
if (!toolConfig.annotations) {
console.error(`Enabled query wrapper "${name}" is missing required "annotations"`)
process.exit(1)
}
if (!querySchema.definitions[toolConfig.schema_ref]) {
console.error(`Query wrapper "${name}": schema_ref "${toolConfig.schema_ref}" not found in schema.json`)
process.exit(1)
}
enabledWrappers.push([name, toolConfig as EnabledQueryWrapperToolConfig])
}
// Generate all Zod schemas first, collecting them to deduplicate shared definitions
const allZodBlocks: string[] = []
const emittedDefs = new Set<string>()
for (const [, toolConfig] of enabledWrappers) {
const excludeProps = [...(toolConfig.exclude_properties ?? [])]
const zodCode = generateZodFromSchemaRef(querySchema, toolConfig.schema_ref, excludeProps)
// Split into individual const declarations and only emit new ones
const lines = zodCode.split('\n\nconst ')
for (let i = 0; i < lines.length; i++) {
const block = i === 0 ? lines[i]! : `const ${lines[i]}`
const match = block.match(/^const (\w+) =/)
if (match && !emittedDefs.has(match[1]!)) {
emittedDefs.add(match[1]!)
allZodBlocks.push(block)
}
}
}
// Generate per-tool schemas when the tool needs to customize the base schema.
const perToolSchemaNames = new Map<string, string>()
for (const [name, toolConfig] of enabledWrappers) {
const hasDefaults = toolConfig.property_defaults && Object.keys(toolConfig.property_defaults).length > 0
const useOptimizedOutput = !!toolConfig.use_optimized_output
if (!hasDefaults && !useOptimizedOutput) {
continue
}
const baseVarName = getEntryVarName(toolConfig.schema_ref)
const toolSchemaName = `${toPascalCase(name)}Schema`
const overrides: string[] = []
for (const [prop, defaultValue] of Object.entries(toolConfig.property_defaults ?? {})) {
overrides.push(
` ${prop}: ${baseVarName}.shape.${prop}.default(${JSON.stringify(defaultValue)}).optional(),`
)
}
if (useOptimizedOutput) {
// When the wrapper has a server-side formatter, expose an `output_format` per-call input
// so the agent can override the default (`optimized`) and request raw JSON. The factory
// strips this field before building the query body so it never leaks to the backend.
const outputFormatDescription =
'Output format. "optimized" returns a human-readable summary from server-side formatters (recommended for analysis). "json" returns the raw query results as JSON.'
overrides.push(
` output_format: z.enum(['optimized', 'json']).default('optimized').optional().describe(${JSON.stringify(outputFormatDescription)}),`
)
}
const extendExpr = `.extend({\n${overrides.join('\n')}\n})`
allZodBlocks.push(`const ${toolSchemaName} = ${baseVarName}${extendExpr}`)
perToolSchemaNames.set(name, toolSchemaName)
}
const schemasCode = allZodBlocks.join('\n\n')
// Generate tool registrations using the factory
const mapEntries = enabledWrappers
.map(([name, toolConfig]) => {
const schemaVarName = perToolSchemaNames.get(name) ?? getEntryVarName(toolConfig.schema_ref)
const kind = extractKindFromSchemaRef(querySchema, toolConfig.schema_ref)
const configParts = [`name: '${name}'`, `schema: ${schemaVarName}`, `kind: '${kind}'`]
if (toolConfig.ui_resource_uri) {
configParts.push(`uiResourceUri: '${toolConfig.ui_resource_uri}'`)
}
// The factory's `outputFormat` drives both the default text encoding and whether the
// wrapper surfaces `formatted_results`. `use_optimized_output` in YAML maps to `'optimized'`;
// everything else falls back to raw JSON.
configParts.push(`outputFormat: '${toolConfig.use_optimized_output ? 'optimized' : 'json'}'`)
if (toolConfig.url_prefix) {
configParts.push(`urlPrefix: '${toolConfig.url_prefix}'`)
}
return ` '${name}': createQueryWrapper({ ${configParts.join(', ')} }),`
})
.join('\n')
const code = `// AUTO-GENERATED from ${fileName} + schema.json — do not edit
import { z } from 'zod'
import type { ZodObjectAny } from '@/tools/types'
import { createQueryWrapper } from '@/tools/query-wrapper-factory'
// --- Shared Zod schemas generated from schema.json ---
${schemasCode}
// --- Tool registrations ---
export const GENERATED_TOOLS: Record<string, ReturnType<typeof createQueryWrapper<ZodObjectAny>>> = {
${mapEntries}
}
`
return { code, enabledWrappers }
}
/** Extract the `kind` const value from a schema.json definition */
function extractKindFromSchemaRef(querySchema: JsonSchemaRoot, schemaRef: string): string {
const schema = querySchema.definitions[schemaRef]
if (schema?.properties?.kind?.const) {
return schema.properties.kind.const as string
}
// Fallback: derive from the schema ref name (e.g. AssistantTrendsQuery → TrendsQuery)
return schemaRef.replace(/^Assistant/, '')
}
function generateQueryWrapperDefinitionsJson(
config: QueryWrappersConfig,
enabledWrappers: [string, EnabledQueryWrapperToolConfig][],
yamlDir: string
): Record<string, unknown> {
const definitions: Record<string, unknown> = {}
for (const [name, toolConfig] of enabledWrappers) {
definitions[name] = {
description: resolveDescription(toolConfig, yamlDir, `Run a ${toolConfig.schema_ref} query`),
category: config.category,
feature: config.feature,
summary: toolConfig.title || name,
title: toolConfig.title || name,
required_scopes: toolConfig.scopes,
annotations: {
destructiveHint: toolConfig.annotations.destructive,
idempotentHint: toolConfig.annotations.idempotent,
openWorldHint: true,
readOnlyHint: toolConfig.annotations.readOnly,
},
...(toolConfig.feature_flag ? { feature_flag: toolConfig.feature_flag } : {}),
...(toolConfig.feature_entitlement ? { feature_entitlement: toolConfig.feature_entitlement } : {}),
...(toolConfig.feature_flag_behavior ? { feature_flag_behavior: toolConfig.feature_flag_behavior } : {}),
...(toolConfig.feature_flag_variant ? { feature_flag_variant: toolConfig.feature_flag_variant } : {}),
...(toolConfig.system_prompt_hint ? { system_prompt_hint: toolConfig.system_prompt_hint } : {}),
}
}
return definitions
}
// ------------------------------------------------------------------
// Main
// ------------------------------------------------------------------
function main(): void {
const spec = loadOpenApi()
const knownTypes = loadKnownSchemaTypes(spec)
const definitionSources = discoverDefinitions({ definitionsDir: DEFINITIONS_DIR, productsDir: PRODUCTS_DIR })
if (definitionSources.length === 0) {
console.error('No YAML definitions found in definitions/ or products/*/mcp/')
process.exit(1)
}
fs.mkdirSync(GENERATED_DIR, { recursive: true })
const allCategories: {
config: CategoryConfig
enabledTools: [string, EnabledToolConfig, ResolvedOperation][]
enabledWrappers: [string, EnabledQueryWrapperToolConfig][]
yamlDir: string
}[] = []
const generatedModules: string[] = []
// Accumulate query wrapper definitions separately
const queryWrapperDefinitions: Record<string, unknown> = {}
let querySchema: JsonSchemaRoot | undefined
for (const def of definitionSources) {
const content = fs.readFileSync(def.filePath, 'utf-8')
const parsed = parseYaml(content)
// Check if this is a query wrapper config
if (isQueryWrappersConfig(parsed)) {
const result = QueryWrappersConfigSchema.safeParse(parsed)
if (!result.success) {
console.error(`Invalid query wrappers YAML in ${def.filePath}:`)
for (const issue of result.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`)
}
process.exit(1)
}
// Lazy-load query schema only when needed
if (!querySchema) {
querySchema = loadQuerySchema()
}
const config = result.data
const label = path.relative(REPO_ROOT, def.filePath)
const { code, enabledWrappers } = generateQueryWrapperFile(config, label, querySchema)
if (enabledWrappers.length > 0) {
generatedModules.push(def.moduleName)
fs.writeFileSync(path.join(GENERATED_DIR, `${def.moduleName}.ts`), code)
Object.assign(
queryWrapperDefinitions,
generateQueryWrapperDefinitionsJson(config, enabledWrappers, path.dirname(def.filePath))
)
process.stdout.write(`Generated ${enabledWrappers.length} query wrapper(s) from ${label}\n`)
}
continue
}
const result = CategoryConfigSchema.safeParse(parsed)
if (!result.success) {
console.error(`Invalid YAML config in ${def.filePath}:`)
for (const issue of result.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`)
}
process.exit(1)
}
const config = result.data
const label = path.relative(REPO_ROOT, def.filePath)
const getQuerySchemaLazy = (): JsonSchemaRoot => {
if (!querySchema) {
querySchema = loadQuerySchema()
}
return querySchema
}
const { code, enabledTools, enabledWrappers } = generateCategoryFile(
config,
label,
def.moduleName,
spec,
knownTypes,
getQuerySchemaLazy
)
if (enabledTools.length > 0 || enabledWrappers.length > 0) {
generatedModules.push(def.moduleName)
allCategories.push({ config, enabledTools, enabledWrappers, yamlDir: path.dirname(def.filePath) })
fs.writeFileSync(path.join(GENERATED_DIR, `${def.moduleName}.ts`), code)
}
}
// Barrel index
const sortedModules = [...generatedModules].sort()
const imports = sortedModules.map((m) => `import { GENERATED_TOOLS as ${toCamelCase(m)} } from './${m}'`).join('\n')
const spreads = sortedModules.map((m) => ` ...${toCamelCase(m)},`).join('\n')
const barrelCode = `// AUTO-GENERATED — do not edit
import type { ToolBase, ZodObjectAny } from '@/tools/types'
${imports}
export const GENERATED_TOOL_MAP: Record<string, () => ToolBase<ZodObjectAny>> = {
${spreads}
}
`
fs.writeFileSync(path.join(GENERATED_DIR, 'index.ts'), barrelCode)
// Tool definitions JSON (merge OpenAPI-based + query wrapper definitions)
// Sort keys so output is stable regardless of upstream OpenAPI iteration order.
const definitions = sortKeys({ ...generateDefinitionsJson(allCategories), ...queryWrapperDefinitions })
fs.writeFileSync(DEFINITIONS_JSON_PATH, JSON.stringify(definitions, null, 4) + '\n')
// Combined tool definitions for external consumers (docs site)
const handwrittenDefinitions = JSON.parse(fs.readFileSync(TOOL_DEFINITIONS_PATH, 'utf-8'))
const allDefinitions = sortKeys({ ...handwrittenDefinitions, ...definitions })
fs.writeFileSync(ALL_DEFINITIONS_JSON_PATH, JSON.stringify(allDefinitions, null, 4) + '\n')
const totalTools = allCategories.reduce((sum, c) => sum + c.enabledTools.length, 0)
const totalQueryWrappers = Object.keys(queryWrapperDefinitions).length
const totalAllTools = Object.keys(allDefinitions).length
process.stdout.write(`Generated ${totalTools} tool(s) from ${allCategories.length} category file(s)\n`)
if (totalQueryWrappers > 0) {
process.stdout.write(`Generated ${totalQueryWrappers} query wrapper tool(s)\n`)
}
process.stdout.write(`Combined ${totalAllTools} total tool(s) into tool-definitions-all.json\n`)
const generatedTsFiles = [
...generatedModules.map((m) => path.join(GENERATED_DIR, `${m}.ts`)),
path.join(GENERATED_DIR, 'index.ts'),
]
spawnSync(path.join(REPO_ROOT, 'bin/insightscli'), ['format:js', ...generatedTsFiles], { stdio: 'pipe', cwd: REPO_ROOT })
spawnSync(path.join(REPO_ROOT, 'bin/insightscli'), ['format:yaml', DEFINITIONS_JSON_PATH, ALL_DEFINITIONS_JSON_PATH], {
stdio: 'pipe',
cwd: REPO_ROOT,
})
}
// Export for testing
export {
buildResponseFilter,
composeToolSchema,
extractPathParams,
generateCategoryFile,
generateCustomSchemaToolCode,
generateDefinitionsJson,
generateQueryWrapperDefinitionsJson,
generateQueryWrapperFile,
generateToolCode,
}
export type { OpenApiSpec, ResolvedOperation }
// Run main when executed directly
function stripExt(filePath: string): string {
return filePath.replace(/\.[jt]s$/, '')
}
const isDirectRun =
typeof process !== 'undefined' &&
process.argv[1] &&
stripExt(path.resolve(process.argv[1])) === stripExt(fileURLToPath(import.meta.url))
if (isDirectRun) {
main()
}