Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 78 additions & 28 deletions packages/nuxt-mcp-toolkit/src/runtime/server/mcp/codemode/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { z } from 'zod'
import { z, type ZodRawShape } from 'zod'
import type { McpToolDefinition, McpToolDefinitionListItem } from '../definitions/tools'
import { enrichNameTitle } from '../definitions/utils'

Expand Down Expand Up @@ -48,6 +48,12 @@ function pascalCase(str: string): string {
return str.replace(/(^|_)(\w)/g, (_, __, c) => c.toUpperCase())
}

function formatTsPropertyKey(key: string): string {
return /^[A-Z_$][\w$]*$/i.test(key) && !RESERVED_WORDS.has(key)
? key
: JSON.stringify(key)
}

function jsonSchemaPropertyToTs(prop: Record<string, unknown>): string {
if (prop.enum && Array.isArray(prop.enum)) {
return prop.enum.map(v => typeof v === 'string' ? `"${v}"` : String(v)).join(' | ')
Expand Down Expand Up @@ -100,11 +106,56 @@ function isPrimitiveProp(prop: Record<string, unknown>): boolean {
return !!type && PRIMITIVE_TYPES.has(type)
}

interface SchemaTypeInfo {
interfaceDecl: string | null
typeExpression: string
}

function generateSchemaTypeInfo(
schema: ZodRawShape,
typeName: string,
): SchemaTypeInfo | null {
const jsonSchema = z.toJSONSchema(z.object(schema))
const properties = jsonSchema.properties as Record<string, Record<string, unknown>> | undefined
const required = (jsonSchema.required as string[]) || []

if (!properties || Object.keys(properties).length === 0) {
return null
}

const entries = Object.entries(properties)
const allPrimitive = entries.every(([, prop]) => isPrimitiveProp(prop))

if (entries.length <= INLINE_THRESHOLD && allPrimitive) {
const inlineFields = entries.map(([key, prop]) => {
const opt = required.includes(key) ? '' : '?'
return `${formatTsPropertyKey(key)}${opt}: ${jsonSchemaPropertyToTs(prop)}`
})

return {
interfaceDecl: null,
typeExpression: `{ ${inlineFields.join('; ')} }`,
}
}

const fields = entries.map(([key, prop]) => {
const opt = required.includes(key) ? '' : '?'
const tsType = jsonSchemaPropertyToTs(prop)
return ` ${formatTsPropertyKey(key)}${opt}: ${tsType};`
})

return {
interfaceDecl: `interface ${typeName} {\n${fields.join('\n')}\n}`,
typeExpression: typeName,
}
}

interface ToolTypeInfo {
originalName: string
sanitizedName: string
typeName: string
interfaceDecl: string | null
outputInterfaceDecl: string | null
methodSignature: string
}

Expand All @@ -124,45 +175,44 @@ function generateToolTypeInfo(tool: McpToolDefinition): ToolTypeInfo {

if (tool.inputSchema && Object.keys(tool.inputSchema).length > 0) {
try {
const jsonSchema = z.toJSONSchema(z.object(tool.inputSchema))
const properties = jsonSchema.properties as Record<string, Record<string, unknown>> | undefined
const required = (jsonSchema.required as string[]) || []

if (properties && Object.keys(properties).length > 0) {
const entries = Object.entries(properties)
const allPrimitive = entries.every(([, prop]) => isPrimitiveProp(prop))

if (entries.length <= INLINE_THRESHOLD && allPrimitive) {
const inlineFields = entries.map(([key, prop]) => {
const opt = required.includes(key) ? '' : '?'
return `${key}${opt}: ${jsonSchemaPropertyToTs(prop)}`
})
paramSignature = `input: { ${inlineFields.join('; ')} }`
}
else {
const fields = entries.map(([key, prop]) => {
const opt = required.includes(key) ? '' : '?'
const tsType = jsonSchemaPropertyToTs(prop)
return ` ${key}${opt}: ${tsType};`
})
interfaceDecl = `interface ${typeName} {\n${fields.join('\n')}\n}`
paramSignature = `input: ${typeName}`
}
const schemaTypeInfo = generateSchemaTypeInfo(tool.inputSchema, typeName)
if (schemaTypeInfo) {
interfaceDecl = schemaTypeInfo.interfaceDecl
paramSignature = `input: ${schemaTypeInfo.typeExpression}`
}
}
catch {
paramSignature = 'input: Record<string, unknown>'
}
}

// Generate output type from outputSchema
let outputInterfaceDecl: string | null = null
let returnType = 'unknown'
const outputTypeName = `${pascalCase(sanitizedName)}Output`

if (tool.outputSchema && Object.keys(tool.outputSchema).length > 0) {
try {
const schemaTypeInfo = generateSchemaTypeInfo(tool.outputSchema, outputTypeName)
if (schemaTypeInfo) {
outputInterfaceDecl = schemaTypeInfo.interfaceDecl
returnType = schemaTypeInfo.typeExpression
}
}
catch {
// Fall through to default Promise<unknown>
}
}

const desc = tool.description ? ` // ${tool.description}` : ''
const methodSignature = `${sanitizedName}: (${paramSignature}) => Promise<unknown>;${desc}`
const methodSignature = `${sanitizedName}: (${paramSignature}) => Promise<${returnType}>;${desc}`

return {
originalName: name,
sanitizedName,
typeName,
interfaceDecl,
outputInterfaceDecl,
methodSignature,
}
}
Expand All @@ -184,7 +234,7 @@ export function generateTypesFromTools(tools: McpToolDefinitionListItem[]): Gene
const toolInfos = tools.map(generateToolTypeInfo)

const interfaces = toolInfos
.map(t => t.interfaceDecl)
.flatMap(t => [t.interfaceDecl, t.outputInterfaceDecl])
.filter(Boolean)
.join('\n\n')

Expand Down Expand Up @@ -221,7 +271,7 @@ export function generateToolCatalog(tools: McpToolDefinitionListItem[]): {
originalName: info.originalName,
description: info.description,
signature: info.methodSignature,
interfaceDecl: info.interfaceDecl || undefined,
interfaceDecl: [info.interfaceDecl, info.outputInterfaceDecl].filter(Boolean).join('\n\n') || undefined,
}))

return { entries, toolNameMap: buildToolNameMap(toolInfos) }
Expand Down
58 changes: 58 additions & 0 deletions packages/nuxt-mcp-toolkit/test/codemode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ function makeTool(name: string, description: string, inputSchema?: Record<string
}
}

function makeToolWithOutput(
name: string,
description: string,
inputSchema: Record<string, z.ZodTypeAny>,
outputSchema: Record<string, z.ZodTypeAny>,
): McpToolDefinition {
return {
name,
description,
inputSchema,
outputSchema,
handler: async () => ({ content: [{ type: 'text' as const, text: 'ok' }] }),
}
}

const sampleTools = [
makeTool('get-user', 'Get a user by ID', { id: z.string() }),
makeTool('list-users', 'List all users'),
Expand Down Expand Up @@ -84,6 +99,49 @@ describe('generateTypesFromTools', () => {
})
})

describe('output type generation', () => {
it('generates inline return type from small outputSchema', () => {
const tools = [makeToolWithOutput('create-item', 'Create', { title: z.string() }, { id: z.string(), ok: z.boolean() })]
const { typeDefinitions } = generateTypesFromTools(tools)
expect(typeDefinitions).toContain('Promise<{ id: string; ok: boolean }>')
expect(typeDefinitions).not.toContain('interface CreateItemOutput')
})

it('quotes output property names that are not TS-safe identifiers', () => {
const tools = [makeToolWithOutput('get-meta', 'Meta', {}, {
'repo-name': z.string(),
'default': z.boolean(),
})]
const { typeDefinitions } = generateTypesFromTools(tools)
expect(typeDefinitions).toContain('Promise<{ "repo-name": string; "default": boolean }>')
})

it('generates named output interface for complex schemas', () => {
const tools = [makeToolWithOutput('get-report', 'Report', {}, {
id: z.string(),
title: z.string(),
status: z.enum(['draft', 'published']),
views: z.number(),
})]
const { typeDefinitions } = generateTypesFromTools(tools)
expect(typeDefinitions).toContain('interface GetReportOutput')
expect(typeDefinitions).toContain('Promise<GetReportOutput>')
})

it('defaults to Promise<unknown> when no outputSchema', () => {
const tools = [makeTool('list-items', 'List items')]
const { typeDefinitions } = generateTypesFromTools(tools)
expect(typeDefinitions).toContain('Promise<unknown>')
expect(typeDefinitions).not.toContain('Output')
})

it('catalog entries reflect output types in signatures', () => {
const tools = [makeToolWithOutput('create-item', 'Create', { title: z.string() }, { id: z.string(), ok: z.boolean() })]
const { entries } = generateToolCatalog(tools)
expect(entries[0]!.signature).toContain('Promise<{ id: string; ok: boolean }>')
})
})

describe('generateToolCatalog', () => {
it('creates catalog entries for all tools', () => {
const { entries, toolNameMap } = generateToolCatalog(sampleTools)
Expand Down
Loading