@kubb/plugin-mcp 5.0.0-alpha.3 → 5.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import path from 'node:path'
2
+ import { caseParams } from '@kubb/ast'
3
+ import { defineGenerator } from '@kubb/core'
4
+ import { pluginZodName } from '@kubb/plugin-zod'
5
+ import { File } from '@kubb/react-fabric'
6
+ import { Server } from '../components/Server.tsx'
7
+ import type { PluginMcp } from '../types.ts'
8
+
9
+ /**
10
+ * Legacy server generator for `compatibilityPreset: 'kubbV4'`.
11
+ *
12
+ * Uses grouped zod schemas for query/header params (e.g. `createPetsQueryParamsSchema`)
13
+ * and `resolveResponseName` for the combined response schema.
14
+ * Path params are always rendered inline (no named imports).
15
+ */
16
+ export const serverGeneratorLegacy = defineGenerator<PluginMcp>({
17
+ name: 'operations',
18
+ operations(nodes, options) {
19
+ const { adapter, config, resolver, plugin, driver, root } = this
20
+ const { output, paramsCasing, group } = options
21
+
22
+ const pluginZod = driver.getPlugin(pluginZodName)
23
+
24
+ if (!pluginZod?.resolver) {
25
+ return
26
+ }
27
+
28
+ const name = 'server'
29
+ const serverFilePath = path.resolve(root, output.path, 'server.ts')
30
+ const serverFile = {
31
+ baseName: 'server.ts' as const,
32
+ path: serverFilePath,
33
+ meta: { pluginName: plugin.name },
34
+ }
35
+
36
+ const jsonFilePath = path.resolve(root, output.path, '.mcp.json')
37
+ const jsonFile = {
38
+ baseName: '.mcp.json' as const,
39
+ path: jsonFilePath,
40
+ meta: { pluginName: plugin.name },
41
+ }
42
+
43
+ const operationsMapped = nodes.map((node) => {
44
+ const casedParams = caseParams(node.parameters, paramsCasing)
45
+ const queryParams = casedParams.filter((p) => p.in === 'query')
46
+ const headerParams = casedParams.filter((p) => p.in === 'header')
47
+
48
+ const mcpFile = resolver.resolveFile({ name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path }, { root, output, group })
49
+
50
+ const zodFile = pluginZod?.resolver.resolveFile(
51
+ { name: node.operationId, extname: '.ts', tag: node.tags[0] ?? 'default', path: node.path },
52
+ {
53
+ root,
54
+ output: pluginZod?.options?.output ?? output,
55
+ group: pluginZod?.options?.group,
56
+ },
57
+ )
58
+
59
+ const requestName = node.requestBody?.schema ? pluginZod?.resolver.resolveDataName(node) : undefined
60
+ const responseName = pluginZod?.resolver.resolveResponseName(node)
61
+
62
+ const zodQueryParams = queryParams.length ? pluginZod?.resolver.resolveQueryParamsName(node, queryParams[0]!) : undefined
63
+
64
+ const zodHeaderParams = headerParams.length ? pluginZod?.resolver.resolveHeaderParamsName(node, headerParams[0]!) : undefined
65
+
66
+ return {
67
+ tool: {
68
+ name: node.operationId,
69
+ title: node.summary || undefined,
70
+ description: node.description || `Make a ${node.method.toUpperCase()} request to ${node.path}`,
71
+ },
72
+ mcp: {
73
+ name: resolver.resolveName(node.operationId),
74
+ file: mcpFile,
75
+ },
76
+ zod: {
77
+ pathParams: [],
78
+ queryParams: zodQueryParams,
79
+ headerParams: zodHeaderParams,
80
+ requestName,
81
+ responseName,
82
+ file: zodFile,
83
+ },
84
+ node: node,
85
+ }
86
+ })
87
+
88
+ const imports = operationsMapped.flatMap(({ mcp, zod }) => {
89
+ const zodNames = [zod.queryParams, zod.headerParams, zod.requestName, zod.responseName].filter(Boolean) as string[]
90
+
91
+ return [
92
+ <File.Import key={mcp.name} name={[mcp.name]} root={serverFile.path} path={mcp.file.path} />,
93
+ zod.file && zodNames.length > 0 && <File.Import key={`zod-${mcp.name}`} name={zodNames.sort()} root={serverFile.path} path={zod.file.path} />,
94
+ ].filter(Boolean)
95
+ })
96
+
97
+ return (
98
+ <>
99
+ <File
100
+ baseName={serverFile.baseName}
101
+ path={serverFile.path}
102
+ meta={serverFile.meta}
103
+ banner={resolver.resolveBanner(adapter.rootNode, { output, config })}
104
+ footer={resolver.resolveFooter(adapter.rootNode, { output, config })}
105
+ >
106
+ <File.Import name={['McpServer']} path={'@modelcontextprotocol/sdk/server/mcp'} />
107
+ <File.Import name={['z']} path={'zod'} />
108
+ <File.Import name={['StdioServerTransport']} path={'@modelcontextprotocol/sdk/server/stdio'} />
109
+
110
+ {imports}
111
+ <Server
112
+ name={name}
113
+ serverName={adapter.rootNode?.meta?.title ?? 'server'}
114
+ serverVersion={(adapter.document as { openapi?: string })?.openapi ?? adapter.rootNode?.meta?.version ?? '0.0.0'}
115
+ paramsCasing={paramsCasing}
116
+ operations={operationsMapped}
117
+ />
118
+ </File>
119
+
120
+ <File baseName={jsonFile.baseName} path={jsonFile.path} meta={jsonFile.meta}>
121
+ <File.Source name={name}>
122
+ {`
123
+ {
124
+ "mcpServers": {
125
+ "${adapter.rootNode?.meta?.title || 'server'}": {
126
+ "type": "stdio",
127
+ "command": "npx",
128
+ "args": ["tsx", "${path.relative(path.dirname(jsonFile.path), serverFile.path)}"]
129
+ }
130
+ }
131
+ }
132
+ `}
133
+ </File.Source>
134
+ </File>
135
+ </>
136
+ )
137
+ },
138
+ })
package/src/index.ts CHANGED
@@ -1,2 +1,12 @@
1
+ export { McpHandler } from './components/McpHandler.tsx'
2
+ export { Server } from './components/Server.tsx'
3
+
4
+ export { mcpGenerator } from './generators/mcpGenerator.tsx'
5
+ export { serverGenerator } from './generators/serverGenerator.tsx'
6
+ export { serverGeneratorLegacy } from './generators/serverGeneratorLegacy.tsx'
7
+
1
8
  export { pluginMcp, pluginMcpName } from './plugin.ts'
2
- export type { PluginMcp } from './types.ts'
9
+
10
+ export { resolverMcp } from './resolvers/resolverMcp.ts'
11
+
12
+ export type { PluginMcp, ResolverMcp } from './types.ts'
package/src/plugin.ts CHANGED
@@ -1,111 +1,110 @@
1
1
  import path from 'node:path'
2
2
  import { camelCase } from '@internals/utils'
3
- import { definePlugin, type Group, getBarrelFiles, getMode } from '@kubb/core'
3
+ import { createPlugin, type Group, getPreset, mergeGenerators } from '@kubb/core'
4
4
  import { pluginClientName } from '@kubb/plugin-client'
5
5
  import { source as axiosClientSource } from '@kubb/plugin-client/templates/clients/axios.source'
6
6
  import { source as fetchClientSource } from '@kubb/plugin-client/templates/clients/fetch.source'
7
7
  import { source as configSource } from '@kubb/plugin-client/templates/config.source'
8
- import { OperationGenerator, pluginOasName } from '@kubb/plugin-oas'
9
8
  import { pluginTsName } from '@kubb/plugin-ts'
10
9
  import { pluginZodName } from '@kubb/plugin-zod'
11
- import { mcpGenerator, serverGenerator } from './generators'
10
+ import { version } from '../package.json'
11
+ import { presets } from './presets.ts'
12
12
  import type { PluginMcp } from './types.ts'
13
13
 
14
14
  export const pluginMcpName = 'plugin-mcp' satisfies PluginMcp['name']
15
15
 
16
- export const pluginMcp = definePlugin<PluginMcp>((options) => {
16
+ export const pluginMcp = createPlugin<PluginMcp>((options) => {
17
17
  const {
18
18
  output = { path: 'mcp', barrelType: 'named' },
19
19
  group,
20
20
  exclude = [],
21
21
  include,
22
22
  override = [],
23
- transformers = {},
24
- generators = [mcpGenerator, serverGenerator].filter(Boolean),
25
- contentType,
26
23
  paramsCasing,
27
24
  client,
25
+ compatibilityPreset = 'default',
26
+ resolver: userResolver,
27
+ transformer: userTransformer,
28
+ generators: userGenerators = [],
28
29
  } = options
29
30
 
30
31
  const clientName = client?.client ?? 'axios'
31
32
  const clientImportPath = client?.importPath ?? (!client?.bundle ? `@kubb/plugin-client/clients/${clientName}` : undefined)
32
33
 
34
+ const preset = getPreset({
35
+ preset: compatibilityPreset,
36
+ presets,
37
+ resolver: userResolver,
38
+ transformer: userTransformer,
39
+ generators: userGenerators,
40
+ })
41
+
42
+ const generators = preset.generators ?? []
43
+ const mergedGenerator = mergeGenerators(generators)
44
+
33
45
  return {
34
46
  name: pluginMcpName,
35
- options: {
36
- output,
37
- group,
38
- paramsCasing,
39
- client: {
40
- client: clientName,
41
- clientType: client?.clientType ?? 'function',
42
- importPath: clientImportPath,
43
- dataReturnType: client?.dataReturnType ?? 'data',
44
- bundle: client?.bundle,
45
- baseURL: client?.baseURL,
46
- paramsCasing: client?.paramsCasing,
47
- },
47
+ version,
48
+ get resolver() {
49
+ return preset.resolver
48
50
  },
49
- pre: [pluginOasName, pluginTsName, pluginZodName].filter(Boolean),
50
- resolvePath(baseName, pathMode, options) {
51
- const root = path.resolve(this.config.root, this.config.output.path)
52
- const mode = pathMode ?? getMode(path.resolve(root, output.path))
53
-
54
- if (mode === 'single') {
55
- /**
56
- * when output is a file then we will always append to the same file(output file), see fileManager.addOrAppend
57
- * Other plugins then need to call addOrAppend instead of just add from the fileManager class
58
- */
59
- return path.resolve(root, output.path)
60
- }
61
-
62
- if (group && (options?.group?.path || options?.group?.tag)) {
63
- const groupName: Group['name'] = group?.name
64
- ? group.name
65
- : (ctx) => {
66
- if (group?.type === 'path') {
67
- return `${ctx.group.split('/')[1]}`
68
- }
69
- return `${camelCase(ctx.group)}Requests`
70
- }
71
-
72
- return path.resolve(
73
- root,
74
- output.path,
75
- groupName({
76
- group: group.type === 'path' ? options.group.path! : options.group.tag!,
77
- }),
78
- baseName,
79
- )
80
- }
81
-
82
- return path.resolve(root, output.path, baseName)
51
+ get transformer() {
52
+ return preset.transformer
83
53
  },
84
- resolveName(name, type) {
85
- const resolvedName = camelCase(name, {
86
- isFile: type === 'file',
87
- })
88
-
89
- if (type) {
90
- return transformers?.name?.(resolvedName, type) || resolvedName
54
+ get options() {
55
+ return {
56
+ output,
57
+ exclude,
58
+ include,
59
+ override,
60
+ group: group
61
+ ? ({
62
+ ...group,
63
+ name: group.name
64
+ ? group.name
65
+ : (ctx: { group: string }) => {
66
+ if (group.type === 'path') {
67
+ return `${ctx.group.split('/')[1]}`
68
+ }
69
+ return `${camelCase(ctx.group)}Requests`
70
+ },
71
+ } satisfies Group)
72
+ : undefined,
73
+ paramsCasing,
74
+ client: {
75
+ client: clientName,
76
+ clientType: client?.clientType ?? 'function',
77
+ importPath: clientImportPath,
78
+ dataReturnType: client?.dataReturnType ?? 'data',
79
+ bundle: client?.bundle,
80
+ baseURL: client?.baseURL,
81
+ paramsCasing: client?.paramsCasing,
82
+ },
83
+ resolver: preset.resolver,
91
84
  }
92
-
93
- return resolvedName
94
85
  },
95
- async install() {
96
- const root = path.resolve(this.config.root, this.config.output.path)
97
- const mode = getMode(path.resolve(root, output.path))
98
- const oas = await this.getOas()
99
- const baseURL = await this.getBaseURL()
86
+ pre: [pluginTsName, pluginZodName].filter(Boolean),
87
+ async schema(node, options) {
88
+ return mergedGenerator.schema?.call(this, node, options)
89
+ },
90
+ async operation(node, options) {
91
+ return mergedGenerator.operation?.call(this, node, options)
92
+ },
93
+ async operations(nodes, options) {
94
+ return mergedGenerator.operations?.call(this, nodes, options)
95
+ },
96
+ async buildStart() {
97
+ const { adapter, driver } = this
98
+ const root = this.root
100
99
 
100
+ const baseURL = adapter?.rootNode?.meta?.baseURL
101
101
  if (baseURL) {
102
- this.plugin.options.client.baseURL = baseURL
102
+ this.plugin.options.client.baseURL = this.plugin.options.client.baseURL || baseURL
103
103
  }
104
104
 
105
- const hasClientPlugin = !!this.pluginManager.getPluginByName(pluginClientName)
105
+ const hasClientPlugin = !!driver.getPlugin(pluginClientName)
106
106
 
107
107
  if (this.plugin.options.client.bundle && !hasClientPlugin && !this.plugin.options.client.importPath) {
108
- // pre add bundled fetch
109
108
  await this.addFile({
110
109
  baseName: 'fetch.ts',
111
110
  path: path.resolve(root, '.kubb/fetch.ts'),
@@ -139,32 +138,7 @@ export const pluginMcp = definePlugin<PluginMcp>((options) => {
139
138
  })
140
139
  }
141
140
 
142
- const operationGenerator = new OperationGenerator(this.plugin.options, {
143
- fabric: this.fabric,
144
- oas,
145
- pluginManager: this.pluginManager,
146
- events: this.events,
147
- plugin: this.plugin,
148
- contentType,
149
- exclude,
150
- include,
151
- override,
152
- mode,
153
- })
154
-
155
- const files = await operationGenerator.build(...generators)
156
- await this.upsertFile(...files)
157
-
158
- const barrelFiles = await getBarrelFiles(this.fabric.files, {
159
- type: output.barrelType ?? 'named',
160
- root,
161
- output,
162
- meta: {
163
- pluginName: this.plugin.name,
164
- },
165
- })
166
-
167
- await this.upsertFile(...barrelFiles)
141
+ await this.openInStudio({ ast: true })
168
142
  },
169
143
  }
170
144
  })
package/src/presets.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { definePresets } from '@kubb/core'
2
+ import { mcpGenerator } from './generators/mcpGenerator.tsx'
3
+ import { serverGenerator } from './generators/serverGenerator.tsx'
4
+ import { serverGeneratorLegacy } from './generators/serverGeneratorLegacy.tsx'
5
+ import { resolverMcp } from './resolvers/resolverMcp.ts'
6
+ import type { ResolverMcp } from './types.ts'
7
+
8
+ /**
9
+ * Built-in preset registry for `@kubb/plugin-mcp`.
10
+ *
11
+ * - `default` — v5 naming with individual zod schemas and per-status responses.
12
+ * - `kubbV4` — legacy naming with grouped zod schemas and combined responses.
13
+ */
14
+ export const presets = definePresets<ResolverMcp>({
15
+ default: {
16
+ name: 'default',
17
+ resolver: resolverMcp,
18
+ generators: [mcpGenerator, serverGenerator],
19
+ },
20
+ kubbV4: {
21
+ name: 'kubbV4',
22
+ resolver: resolverMcp,
23
+ generators: [mcpGenerator, serverGeneratorLegacy],
24
+ },
25
+ })
@@ -0,0 +1,29 @@
1
+ import { camelCase } from '@internals/utils'
2
+ import { defineResolver } from '@kubb/core'
3
+ import type { PluginMcp } from '../types.ts'
4
+
5
+ /**
6
+ * Resolver for `@kubb/plugin-mcp` that provides the default naming
7
+ * and path-resolution helpers used by the plugin.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { resolverMcp } from '@kubb/plugin-mcp'
12
+ *
13
+ * resolverMcp.default('addPet', 'function') // -> 'addPetHandler'
14
+ * resolverMcp.resolveName('show pet by id') // -> 'showPetByIdHandler'
15
+ * ```
16
+ */
17
+ export const resolverMcp = defineResolver<PluginMcp>(() => ({
18
+ name: 'default',
19
+ pluginName: 'plugin-mcp',
20
+ default(name, type) {
21
+ if (type === 'file') {
22
+ return camelCase(name, { isFile: true })
23
+ }
24
+ return camelCase(name, { suffix: 'handler' })
25
+ },
26
+ resolveName(name) {
27
+ return this.default(name, 'function')
28
+ },
29
+ }))
package/src/types.ts CHANGED
@@ -1,21 +1,41 @@
1
- import type { Group, Output, PluginFactoryOptions, ResolveNameParams } from '@kubb/core'
2
-
3
- import type { contentType, Oas } from '@kubb/oas'
1
+ import type { Visitor } from '@kubb/ast/types'
2
+ import type {
3
+ CompatibilityPreset,
4
+ Exclude,
5
+ Generator,
6
+ Group,
7
+ Include,
8
+ Output,
9
+ Override,
10
+ PluginFactoryOptions,
11
+ ResolvePathOptions,
12
+ Resolver,
13
+ UserGroup,
14
+ } from '@kubb/core'
4
15
  import type { ClientImportPath, PluginClient } from '@kubb/plugin-client'
5
- import type { Exclude, Include, Override, ResolvePathOptions } from '@kubb/plugin-oas'
6
- import type { Generator } from '@kubb/plugin-oas/generators'
16
+
17
+ /**
18
+ * The concrete resolver type for `@kubb/plugin-mcp`.
19
+ * Extends the base `Resolver` with a `resolveName` helper for MCP handler function names.
20
+ */
21
+ export type ResolverMcp = Resolver & {
22
+ /**
23
+ * Resolves the handler function name for a given raw operation name.
24
+ * @example
25
+ * resolver.resolveName('show pet by id') // -> 'showPetByIdHandler'
26
+ */
27
+ resolveName(this: ResolverMcp, name: string): string
28
+ }
7
29
 
8
30
  export type Options = {
9
31
  /**
10
- * Specify the export location for the files and define the behavior of the output
32
+ * Specify the export location for the files and define the behavior of the output.
11
33
  * @default { path: 'mcp', barrelType: 'named' }
12
34
  */
13
- output?: Output<Oas>
35
+ output?: Output
14
36
  /**
15
- * Define which contentType should be used.
16
- * By default, the first JSON valid mediaType is used
37
+ * Client configuration for HTTP request generation.
17
38
  */
18
- contentType?: contentType
19
39
  client?: ClientImportPath & Pick<PluginClient['options'], 'clientType' | 'dataReturnType' | 'baseURL' | 'bundle' | 'paramsCasing'>
20
40
  /**
21
41
  * Transform parameter names to a specific casing format.
@@ -25,9 +45,9 @@ export type Options = {
25
45
  */
26
46
  paramsCasing?: 'camelcase'
27
47
  /**
28
- * Group the mcp requests based on the provided name.
48
+ * Group the MCP requests based on the provided name.
29
49
  */
30
- group?: Group
50
+ group?: UserGroup
31
51
  /**
32
52
  * Array containing exclude parameters to exclude/skip tags/operations/methods/paths.
33
53
  */
@@ -40,23 +60,44 @@ export type Options = {
40
60
  * Array containing override parameters to override `options` based on tags/operations/methods/paths.
41
61
  */
42
62
  override?: Array<Override<ResolvedOptions>>
43
- transformers?: {
44
- /**
45
- * Customize the names based on the type that is provided by the plugin.
46
- */
47
- name?: (name: ResolveNameParams['name'], type?: ResolveNameParams['type']) => string
48
- }
49
63
  /**
50
- * Define some generators next to the Mcp generators.
64
+ * Apply a compatibility naming preset.
65
+ * @default 'default'
66
+ */
67
+ compatibilityPreset?: CompatibilityPreset
68
+ /**
69
+ * A single resolver whose methods override the default resolver's naming conventions.
70
+ * When a method returns `null` or `undefined`, the default resolver's result is used instead.
71
+ */
72
+ resolver?: Partial<ResolverMcp> & ThisType<ResolverMcp>
73
+ /**
74
+ * A single AST visitor applied before printing.
75
+ * When a visitor method returns `null` or `undefined`, the preset transformer's result is used instead.
76
+ */
77
+ transformer?: Visitor
78
+ /**
79
+ * Define some generators next to the default MCP generators.
51
80
  */
52
81
  generators?: Array<Generator<PluginMcp>>
53
82
  }
54
83
 
55
84
  type ResolvedOptions = {
56
- output: Output<Oas>
57
- group: Options['group']
85
+ output: Output
86
+ exclude: Array<Exclude>
87
+ include: Array<Include> | undefined
88
+ override: Array<Override<ResolvedOptions>>
89
+ group: Group | undefined
58
90
  client: Pick<PluginClient['options'], 'client' | 'clientType' | 'dataReturnType' | 'importPath' | 'baseURL' | 'bundle' | 'paramsCasing'>
59
91
  paramsCasing: Options['paramsCasing']
92
+ resolver: ResolverMcp
60
93
  }
61
94
 
62
- export type PluginMcp = PluginFactoryOptions<'plugin-mcp', Options, ResolvedOptions, never, ResolvePathOptions>
95
+ export type PluginMcp = PluginFactoryOptions<'plugin-mcp', Options, ResolvedOptions, never, ResolvePathOptions, ResolverMcp>
96
+
97
+ declare global {
98
+ namespace Kubb {
99
+ interface PluginRegistry {
100
+ 'plugin-mcp': PluginMcp
101
+ }
102
+ }
103
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { camelCase } from '@internals/utils'
2
+ import type { OperationNode, SchemaNode, StatusCode } from '@kubb/ast/types'
3
+
4
+ /**
5
+ * Find the first 2xx response status code from an operation's responses.
6
+ */
7
+ export function findSuccessStatusCode(responses: Array<{ statusCode: number | string }>): StatusCode | undefined {
8
+ for (const res of responses) {
9
+ const code = Number(res.statusCode)
10
+ if (code >= 200 && code < 300) {
11
+ return res.statusCode as StatusCode
12
+ }
13
+ }
14
+ return undefined
15
+ }
16
+
17
+ export type ZodParam = {
18
+ name: string
19
+ schemaName: string
20
+ }
21
+
22
+ /**
23
+ * Render a group param value — either a group schema name directly (kubbV4),
24
+ * or compose individual schemas into `z.object({ ... })` (v5).
25
+ */
26
+ export function zodGroupExpr(entry: string | Array<ZodParam>): string {
27
+ if (typeof entry === 'string') {
28
+ return entry
29
+ }
30
+ const entries = entry.map((p) => `${JSON.stringify(p.name)}: ${p.schemaName}`)
31
+ return `z.object({ ${entries.join(', ')} })`
32
+ }
33
+
34
+ /**
35
+ * Build JSDoc comment lines from an OperationNode.
36
+ */
37
+ export function getComments(node: OperationNode): Array<string> {
38
+ return [
39
+ node.description && `@description ${node.description}`,
40
+ node.summary && `@summary ${node.summary}`,
41
+ node.deprecated && '@deprecated',
42
+ `{@link ${node.path.replaceAll('{', ':').replaceAll('}', '')}}`,
43
+ ].filter((x): x is string => Boolean(x))
44
+ }
45
+
46
+ /**
47
+ * Build a mapping of original param names → camelCase names.
48
+ * Returns `undefined` when no names actually change (no remapping needed).
49
+ */
50
+ export function getParamsMapping(params: Array<{ name: string }>): Record<string, string> | undefined {
51
+ if (!params.length) {
52
+ return undefined
53
+ }
54
+
55
+ const mapping: Record<string, string> = {}
56
+ let hasDifference = false
57
+
58
+ for (const p of params) {
59
+ const camelName = camelCase(p.name)
60
+ mapping[p.name] = camelName
61
+ if (p.name !== camelName) {
62
+ hasDifference = true
63
+ }
64
+ }
65
+
66
+ return hasDifference ? mapping : undefined
67
+ }
68
+
69
+ /**
70
+ * Convert a SchemaNode type to an inline Zod expression string.
71
+ * Used as fallback when no named zod schema is available for a path parameter.
72
+ */
73
+ export function zodExprFromSchemaNode(schema: SchemaNode): string {
74
+ let expr: string
75
+ switch (schema.type) {
76
+ case 'integer':
77
+ expr = 'z.coerce.number()'
78
+ break
79
+ case 'number':
80
+ expr = 'z.number()'
81
+ break
82
+ case 'boolean':
83
+ expr = 'z.boolean()'
84
+ break
85
+ case 'array':
86
+ expr = 'z.array(z.unknown())'
87
+ break
88
+ default:
89
+ expr = 'z.string()'
90
+ }
91
+
92
+ if (schema.nullable) {
93
+ expr = `${expr}.nullable()`
94
+ }
95
+
96
+ return expr
97
+ }