@kubb/plugin-mcp 5.0.0-alpha.27 → 5.0.0-alpha.28

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,144 @@
1
+ import path from 'node:path'
2
+ import { caseParams, transform } from '@kubb/ast'
3
+ import { defineGenerator } from '@kubb/core'
4
+ import type { PluginZod } from '@kubb/plugin-zod'
5
+ import { pluginZodName } from '@kubb/plugin-zod'
6
+ import { File } from '@kubb/react-fabric'
7
+ import { Server } from '../components/Server.tsx'
8
+ import type { PluginMcp } from '../types.ts'
9
+
10
+ /**
11
+ * Legacy server generator for `compatibilityPreset: 'kubbV4'`.
12
+ *
13
+ * Uses grouped zod schemas for query/header params (e.g. `createPetsQueryParamsSchema`)
14
+ * and `resolveResponseName` for the combined response schema.
15
+ * Path params are always rendered inline (no named imports).
16
+ */
17
+ export const serverGeneratorLegacy = defineGenerator<PluginMcp>({
18
+ name: 'operations',
19
+ type: 'react',
20
+ Operations({ nodes, adapter, options, config, driver, resolver, plugin }) {
21
+ const { output, paramsCasing, group } = options
22
+ const root = path.resolve(config.root, config.output.path)
23
+
24
+ const pluginZod = driver.getPlugin<PluginZod>(pluginZodName)
25
+
26
+ if (!pluginZod?.resolver) {
27
+ return
28
+ }
29
+
30
+ const name = 'server'
31
+ const serverFilePath = path.resolve(root, output.path, 'server.ts')
32
+ const serverFile = {
33
+ baseName: 'server.ts' as const,
34
+ path: serverFilePath,
35
+ meta: { pluginName: plugin.name },
36
+ }
37
+
38
+ const jsonFilePath = path.resolve(root, output.path, '.mcp.json')
39
+ const jsonFile = {
40
+ baseName: '.mcp.json' as const,
41
+ path: jsonFilePath,
42
+ meta: { pluginName: plugin.name },
43
+ }
44
+
45
+ const operationsMapped = nodes.map((node) => {
46
+ const transformedNode = plugin.transformer ? transform(node, plugin.transformer) : node
47
+ const casedParams = caseParams(transformedNode.parameters, paramsCasing)
48
+ const queryParams = casedParams.filter((p) => p.in === 'query')
49
+ const headerParams = casedParams.filter((p) => p.in === 'header')
50
+
51
+ const mcpFile = resolver.resolveFile(
52
+ { name: transformedNode.operationId, extname: '.ts', tag: transformedNode.tags[0] ?? 'default', path: transformedNode.path },
53
+ { root, output, group },
54
+ )
55
+
56
+ const zodFile = pluginZod?.resolver.resolveFile(
57
+ { name: transformedNode.operationId, extname: '.ts', tag: transformedNode.tags[0] ?? 'default', path: transformedNode.path },
58
+ {
59
+ root,
60
+ output: pluginZod?.options?.output ?? output,
61
+ group: pluginZod?.options?.group,
62
+ },
63
+ )
64
+
65
+ const requestName = transformedNode.requestBody?.schema ? pluginZod?.resolver.resolveDataName(transformedNode) : undefined
66
+ const responseName = pluginZod?.resolver.resolveResponseName(transformedNode)
67
+
68
+ const zodQueryParams = queryParams.length ? pluginZod?.resolver.resolveQueryParamsName(transformedNode, queryParams[0]!) : undefined
69
+
70
+ const zodHeaderParams = headerParams.length ? pluginZod?.resolver.resolveHeaderParamsName(transformedNode, headerParams[0]!) : undefined
71
+
72
+ return {
73
+ tool: {
74
+ name: transformedNode.operationId,
75
+ title: transformedNode.summary || undefined,
76
+ description: transformedNode.description || `Make a ${transformedNode.method.toUpperCase()} request to ${transformedNode.path}`,
77
+ },
78
+ mcp: {
79
+ name: resolver.resolveName(transformedNode.operationId),
80
+ file: mcpFile,
81
+ },
82
+ zod: {
83
+ pathParams: [],
84
+ queryParams: zodQueryParams,
85
+ headerParams: zodHeaderParams,
86
+ requestName,
87
+ responseName,
88
+ file: zodFile,
89
+ },
90
+ node: transformedNode,
91
+ }
92
+ })
93
+
94
+ const imports = operationsMapped.flatMap(({ mcp, zod }) => {
95
+ const zodNames = [zod.queryParams, zod.headerParams, zod.requestName, zod.responseName].filter(Boolean) as string[]
96
+
97
+ return [
98
+ <File.Import key={mcp.name} name={[mcp.name]} root={serverFile.path} path={mcp.file.path} />,
99
+ zod.file && zodNames.length > 0 && <File.Import key={`zod-${mcp.name}`} name={zodNames.sort()} root={serverFile.path} path={zod.file.path} />,
100
+ ].filter(Boolean)
101
+ })
102
+
103
+ return (
104
+ <>
105
+ <File
106
+ baseName={serverFile.baseName}
107
+ path={serverFile.path}
108
+ meta={serverFile.meta}
109
+ banner={resolver.resolveBanner(adapter.rootNode, { output, config })}
110
+ footer={resolver.resolveFooter(adapter.rootNode, { output, config })}
111
+ >
112
+ <File.Import name={['McpServer']} path={'@modelcontextprotocol/sdk/server/mcp'} />
113
+ <File.Import name={['z']} path={'zod'} />
114
+ <File.Import name={['StdioServerTransport']} path={'@modelcontextprotocol/sdk/server/stdio'} />
115
+
116
+ {imports}
117
+ <Server
118
+ name={name}
119
+ serverName={adapter.rootNode?.meta?.title ?? 'server'}
120
+ serverVersion={(adapter.document as { openapi?: string })?.openapi ?? adapter.rootNode?.meta?.version ?? '0.0.0'}
121
+ paramsCasing={paramsCasing}
122
+ operations={operationsMapped}
123
+ />
124
+ </File>
125
+
126
+ <File baseName={jsonFile.baseName} path={jsonFile.path} meta={jsonFile.meta}>
127
+ <File.Source name={name}>
128
+ {`
129
+ {
130
+ "mcpServers": {
131
+ "${adapter.rootNode?.meta?.title || 'server'}": {
132
+ "type": "stdio",
133
+ "command": "npx",
134
+ "args": ["tsx", "${path.relative(path.dirname(jsonFile.path), serverFile.path)}"]
135
+ }
136
+ }
137
+ }
138
+ `}
139
+ </File.Source>
140
+ </File>
141
+ </>
142
+ )
143
+ },
144
+ })
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,14 +1,15 @@
1
1
  import path from 'node:path'
2
2
  import { camelCase } from '@internals/utils'
3
- import { createPlugin, getBarrelFiles, getMode, type UserGroup } from '@kubb/core'
3
+ import { walk } from '@kubb/ast'
4
+ import type { OperationNode } from '@kubb/ast/types'
5
+ import { createPlugin, type Group, getBarrelFiles, getPreset, runGeneratorOperation, runGeneratorOperations, runGeneratorSchema } from '@kubb/core'
4
6
  import { type PluginClient, pluginClientName } from '@kubb/plugin-client'
5
7
  import { source as axiosClientSource } from '@kubb/plugin-client/templates/clients/axios.source'
6
8
  import { source as fetchClientSource } from '@kubb/plugin-client/templates/clients/fetch.source'
7
9
  import { source as configSource } from '@kubb/plugin-client/templates/config.source'
8
- import { OperationGenerator, pluginOasName } from '@kubb/plugin-oas'
9
10
  import { pluginTsName } from '@kubb/plugin-ts'
10
11
  import { pluginZodName } from '@kubb/plugin-zod'
11
- import { mcpGenerator, serverGenerator } from './generators'
12
+ import { presets } from './presets.ts'
12
13
  import type { PluginMcp } from './types.ts'
13
14
 
14
15
  export const pluginMcpName = 'plugin-mcp' satisfies PluginMcp['name']
@@ -20,92 +21,80 @@ export const pluginMcp = createPlugin<PluginMcp>((options) => {
20
21
  exclude = [],
21
22
  include,
22
23
  override = [],
23
- transformers = {},
24
- generators = [mcpGenerator, serverGenerator].filter(Boolean),
25
- contentType,
26
24
  paramsCasing,
27
25
  client,
26
+ compatibilityPreset = 'default',
27
+ resolver: userResolver,
28
+ transformer: userTransformer,
29
+ generators: userGenerators = [],
28
30
  } = options
29
31
 
30
32
  const clientName = client?.client ?? 'axios'
31
33
  const clientImportPath = client?.importPath ?? (!client?.bundle ? `@kubb/plugin-client/clients/${clientName}` : undefined)
32
34
 
35
+ const preset = getPreset({
36
+ preset: compatibilityPreset,
37
+ presets,
38
+ resolver: userResolver,
39
+ transformer: userTransformer,
40
+ generators: userGenerators,
41
+ })
42
+
33
43
  return {
34
44
  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
- },
45
+ get resolver() {
46
+ return preset.resolver
48
47
  },
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: UserGroup['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)
48
+ get transformer() {
49
+ return preset.transformer
83
50
  },
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
51
+ get options() {
52
+ return {
53
+ output,
54
+ group: group
55
+ ? ({
56
+ ...group,
57
+ name: group.name
58
+ ? group.name
59
+ : (ctx: { group: string }) => {
60
+ if (group.type === 'path') {
61
+ return `${ctx.group.split('/')[1]}`
62
+ }
63
+ return `${camelCase(ctx.group)}Requests`
64
+ },
65
+ } satisfies Group)
66
+ : undefined,
67
+ paramsCasing,
68
+ client: {
69
+ client: clientName,
70
+ clientType: client?.clientType ?? 'function',
71
+ importPath: clientImportPath,
72
+ dataReturnType: client?.dataReturnType ?? 'data',
73
+ bundle: client?.bundle,
74
+ baseURL: client?.baseURL,
75
+ paramsCasing: client?.paramsCasing,
76
+ },
77
+ resolver: preset.resolver,
91
78
  }
92
-
93
- return resolvedName
94
79
  },
80
+ pre: [pluginTsName, pluginZodName].filter(Boolean),
95
81
  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()
82
+ const { config, fabric, plugin, adapter, rootNode, driver } = this
83
+ const root = path.resolve(config.root, config.output.path)
84
+ const resolver = preset.resolver
85
+
86
+ if (!adapter) {
87
+ throw new Error('Plugin cannot work without adapter being set')
88
+ }
100
89
 
90
+ const baseURL = adapter.rootNode?.meta?.baseURL
101
91
  if (baseURL) {
102
- this.plugin.options.client.baseURL = baseURL
92
+ this.plugin.options.client.baseURL = this.plugin.options.client.baseURL || baseURL
103
93
  }
104
94
 
105
- const hasClientPlugin = !!this.getPlugin<PluginClient>(pluginClientName)
95
+ const hasClientPlugin = !!driver.getPlugin<PluginClient>(pluginClientName)
106
96
 
107
97
  if (this.plugin.options.client.bundle && !hasClientPlugin && !this.plugin.options.client.importPath) {
108
- // pre add bundled fetch
109
98
  await this.addFile({
110
99
  baseName: 'fetch.ts',
111
100
  path: path.resolve(root, '.kubb/fetch.ts'),
@@ -139,21 +128,26 @@ export const pluginMcp = createPlugin<PluginMcp>((options) => {
139
128
  })
140
129
  }
141
130
 
142
- const operationGenerator = new OperationGenerator(this.plugin.options, {
143
- fabric: this.fabric,
144
- oas,
145
- driver: this.driver,
146
- events: this.events,
147
- plugin: this.plugin,
148
- contentType,
149
- exclude,
150
- include,
151
- override,
152
- mode,
131
+ const collectedOperations: Array<OperationNode> = []
132
+ const generatorContext = { generators: preset.generators, plugin, resolver, exclude, include, override, fabric, adapter, config, driver }
133
+
134
+ await walk(rootNode, {
135
+ depth: 'shallow',
136
+ async schema(schemaNode) {
137
+ await runGeneratorSchema(schemaNode, generatorContext)
138
+ },
139
+ async operation(operationNode) {
140
+ const baseOptions = resolver.resolveOptions(operationNode, { options: plugin.options, exclude, include, override })
141
+
142
+ if (baseOptions !== null) {
143
+ collectedOperations.push(operationNode)
144
+ }
145
+
146
+ await runGeneratorOperation(operationNode, generatorContext)
147
+ },
153
148
  })
154
149
 
155
- const files = await operationGenerator.build(...generators)
156
- await this.upsertFile(...files)
150
+ await runGeneratorOperations(collectedOperations, generatorContext)
157
151
 
158
152
  const barrelFiles = await getBarrelFiles(this.fabric.files, {
159
153
  type: output.barrelType ?? 'named',
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 { Output, PluginFactoryOptions, ResolveNameParams, UserGroup } 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,7 +45,7 @@ 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
50
  group?: UserGroup
31
51
  /**
@@ -40,23 +60,33 @@ 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
+ group: Group | undefined
58
87
  client: Pick<PluginClient['options'], 'client' | 'clientType' | 'dataReturnType' | 'importPath' | 'baseURL' | 'bundle' | 'paramsCasing'>
59
88
  paramsCasing: Options['paramsCasing']
89
+ resolver: ResolverMcp
60
90
  }
61
91
 
62
- export type PluginMcp = PluginFactoryOptions<'plugin-mcp', Options, ResolvedOptions, never, ResolvePathOptions>
92
+ export type PluginMcp = PluginFactoryOptions<'plugin-mcp', Options, ResolvedOptions, never, ResolvePathOptions, ResolverMcp>
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
+ }