mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
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.
2402 lines
104 KiB
TypeScript
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()
|
|
}
|