source dump of claude code
at main 336 lines 9.4 kB view raw
1import type { 2 EnumSchema, 3 MultiSelectEnumSchema, 4 PrimitiveSchemaDefinition, 5 StringSchema, 6} from '@modelcontextprotocol/sdk/types.js' 7import { z } from 'zod/v4' 8import { jsonStringify } from '../slowOperations.js' 9import { plural } from '../stringUtils.js' 10import { 11 looksLikeISO8601, 12 parseNaturalLanguageDateTime, 13} from './dateTimeParser.js' 14 15export type ValidationResult = { 16 value?: string | number | boolean 17 isValid: boolean 18 error?: string 19} 20 21const STRING_FORMATS = { 22 email: { 23 description: 'email address', 24 example: 'user@example.com', 25 }, 26 uri: { 27 description: 'URI', 28 example: 'https://example.com', 29 }, 30 date: { 31 description: 'date', 32 example: '2024-03-15', 33 }, 34 'date-time': { 35 description: 'date-time', 36 example: '2024-03-15T14:30:00Z', 37 }, 38} 39 40/** 41 * Check if schema is a single-select enum (either legacy `enum` format or new `oneOf` format) 42 */ 43export const isEnumSchema = ( 44 schema: PrimitiveSchemaDefinition, 45): schema is EnumSchema => { 46 return schema.type === 'string' && ('enum' in schema || 'oneOf' in schema) 47} 48 49/** 50 * Check if schema is a multi-select enum (`type: "array"` with `items.enum` or `items.anyOf`) 51 */ 52export function isMultiSelectEnumSchema( 53 schema: PrimitiveSchemaDefinition, 54): schema is MultiSelectEnumSchema { 55 return ( 56 schema.type === 'array' && 57 'items' in schema && 58 typeof schema.items === 'object' && 59 schema.items !== null && 60 ('enum' in schema.items || 'anyOf' in schema.items) 61 ) 62} 63 64/** 65 * Get values from a multi-select enum schema 66 */ 67export function getMultiSelectValues(schema: MultiSelectEnumSchema): string[] { 68 if ('anyOf' in schema.items) { 69 return schema.items.anyOf.map(item => item.const) 70 } 71 if ('enum' in schema.items) { 72 return schema.items.enum 73 } 74 return [] 75} 76 77/** 78 * Get display labels from a multi-select enum schema 79 */ 80export function getMultiSelectLabels(schema: MultiSelectEnumSchema): string[] { 81 if ('anyOf' in schema.items) { 82 return schema.items.anyOf.map(item => item.title) 83 } 84 if ('enum' in schema.items) { 85 return schema.items.enum 86 } 87 return [] 88} 89 90/** 91 * Get label for a specific value in a multi-select enum 92 */ 93export function getMultiSelectLabel( 94 schema: MultiSelectEnumSchema, 95 value: string, 96): string { 97 const index = getMultiSelectValues(schema).indexOf(value) 98 return index >= 0 ? (getMultiSelectLabels(schema)[index] ?? value) : value 99} 100 101/** 102 * Get enum values from EnumSchema (handles both legacy `enum` and new `oneOf` formats) 103 */ 104export function getEnumValues(schema: EnumSchema): string[] { 105 if ('oneOf' in schema) { 106 return schema.oneOf.map(item => item.const) 107 } 108 if ('enum' in schema) { 109 return schema.enum 110 } 111 return [] 112} 113 114/** 115 * Get enum display labels from EnumSchema 116 */ 117export function getEnumLabels(schema: EnumSchema): string[] { 118 if ('oneOf' in schema) { 119 return schema.oneOf.map(item => item.title) 120 } 121 if ('enum' in schema) { 122 return ('enumNames' in schema ? schema.enumNames : undefined) ?? schema.enum 123 } 124 return [] 125} 126 127/** 128 * Get label for a specific enum value 129 */ 130export function getEnumLabel(schema: EnumSchema, value: string): string { 131 const index = getEnumValues(schema).indexOf(value) 132 return index >= 0 ? (getEnumLabels(schema)[index] ?? value) : value 133} 134 135function getZodSchema(schema: PrimitiveSchemaDefinition): z.ZodTypeAny { 136 if (isEnumSchema(schema)) { 137 const [first, ...rest] = getEnumValues(schema) 138 if (!first) { 139 return z.never() 140 } 141 return z.enum([first, ...rest]) 142 } 143 if (schema.type === 'string') { 144 let stringSchema = z.string() 145 if (schema.minLength !== undefined) { 146 stringSchema = stringSchema.min(schema.minLength, { 147 message: `Must be at least ${schema.minLength} ${plural(schema.minLength, 'character')}`, 148 }) 149 } 150 if (schema.maxLength !== undefined) { 151 stringSchema = stringSchema.max(schema.maxLength, { 152 message: `Must be at most ${schema.maxLength} ${plural(schema.maxLength, 'character')}`, 153 }) 154 } 155 switch (schema.format) { 156 case 'email': 157 stringSchema = stringSchema.email({ 158 message: 'Must be a valid email address, e.g. user@example.com', 159 }) 160 break 161 case 'uri': 162 stringSchema = stringSchema.url({ 163 message: 'Must be a valid URI, e.g. https://example.com', 164 }) 165 break 166 case 'date': 167 stringSchema = stringSchema.date( 168 'Must be a valid date, e.g. 2024-03-15, today, next Monday', 169 ) 170 break 171 case 'date-time': 172 stringSchema = stringSchema.datetime({ 173 offset: true, 174 message: 175 'Must be a valid date-time, e.g. 2024-03-15T14:30:00Z, tomorrow at 3pm', 176 }) 177 break 178 default: 179 // No specific format validation 180 break 181 } 182 return stringSchema 183 } 184 if (schema.type === 'number' || schema.type === 'integer') { 185 const typeLabel = schema.type === 'integer' ? 'an integer' : 'a number' 186 const isInteger = schema.type === 'integer' 187 const formatNum = (n: number) => 188 Number.isInteger(n) && !isInteger ? `${n}.0` : String(n) 189 190 // Build a single descriptive error message for range violations 191 const rangeMsg = 192 schema.minimum !== undefined && schema.maximum !== undefined 193 ? `Must be ${typeLabel} between ${formatNum(schema.minimum)} and ${formatNum(schema.maximum)}` 194 : schema.minimum !== undefined 195 ? `Must be ${typeLabel} >= ${formatNum(schema.minimum)}` 196 : schema.maximum !== undefined 197 ? `Must be ${typeLabel} <= ${formatNum(schema.maximum)}` 198 : `Must be ${typeLabel}` 199 200 let numberSchema = z.coerce.number({ 201 error: rangeMsg, 202 }) 203 if (schema.type === 'integer') { 204 numberSchema = numberSchema.int({ message: rangeMsg }) 205 } 206 if (schema.minimum !== undefined) { 207 numberSchema = numberSchema.min(schema.minimum, { 208 message: rangeMsg, 209 }) 210 } 211 if (schema.maximum !== undefined) { 212 numberSchema = numberSchema.max(schema.maximum, { 213 message: rangeMsg, 214 }) 215 } 216 return numberSchema 217 } 218 if (schema.type === 'boolean') { 219 return z.coerce.boolean() 220 } 221 222 throw new Error(`Unsupported schema: ${jsonStringify(schema)}`) 223} 224 225export function validateElicitationInput( 226 stringValue: string, 227 schema: PrimitiveSchemaDefinition, 228): ValidationResult { 229 const zodSchema = getZodSchema(schema) 230 const parseResult = zodSchema.safeParse(stringValue) 231 232 if (parseResult.success) { 233 // zodSchema always produces primitive types for elicitation 234 return { 235 value: parseResult.data as string | number | boolean, 236 isValid: true, 237 } 238 } 239 return { 240 isValid: false, 241 error: parseResult.error.issues.map(e => e.message).join('; '), 242 } 243} 244 245const hasStringFormat = ( 246 schema: PrimitiveSchemaDefinition, 247): schema is StringSchema & { format: string } => { 248 return ( 249 schema.type === 'string' && 250 'format' in schema && 251 typeof schema.format === 'string' 252 ) 253} 254 255/** 256 * Returns a helpful placeholder/hint for a given format 257 */ 258export function getFormatHint( 259 schema: PrimitiveSchemaDefinition, 260): string | undefined { 261 if (schema.type === 'string') { 262 if (!hasStringFormat(schema)) { 263 return undefined 264 } 265 266 const { description, example } = STRING_FORMATS[schema.format] || {} 267 return `${description}, e.g. ${example}` 268 } 269 270 if (schema.type === 'number' || schema.type === 'integer') { 271 const isInteger = schema.type === 'integer' 272 const formatNum = (n: number) => 273 Number.isInteger(n) && !isInteger ? `${n}.0` : String(n) 274 275 if (schema.minimum !== undefined && schema.maximum !== undefined) { 276 return `(${schema.type} between ${formatNum(schema.minimum!)} and ${formatNum(schema.maximum!)})` 277 } else if (schema.minimum !== undefined) { 278 return `(${schema.type} >= ${formatNum(schema.minimum!)})` 279 } else if (schema.maximum !== undefined) { 280 return `(${schema.type} <= ${formatNum(schema.maximum!)})` 281 } else { 282 const example = schema.type === 'integer' ? '42' : '3.14' 283 return `(${schema.type}, e.g. ${example})` 284 } 285 } 286 287 return undefined 288} 289 290/** 291 * Check if a schema is a date or date-time format that supports NL parsing 292 */ 293export function isDateTimeSchema( 294 schema: PrimitiveSchemaDefinition, 295): schema is StringSchema & { format: 'date' | 'date-time' } { 296 return ( 297 schema.type === 'string' && 298 'format' in schema && 299 (schema.format === 'date' || schema.format === 'date-time') 300 ) 301} 302 303/** 304 * Async validation that attempts NL date/time parsing via Haiku 305 * when the input doesn't look like ISO 8601. 306 */ 307export async function validateElicitationInputAsync( 308 stringValue: string, 309 schema: PrimitiveSchemaDefinition, 310 signal: AbortSignal, 311): Promise<ValidationResult> { 312 const syncResult = validateElicitationInput(stringValue, schema) 313 if (syncResult.isValid) { 314 return syncResult 315 } 316 317 if (isDateTimeSchema(schema) && !looksLikeISO8601(stringValue)) { 318 const parseResult = await parseNaturalLanguageDateTime( 319 stringValue, 320 schema.format, 321 signal, 322 ) 323 324 if (parseResult.success) { 325 const validatedParsed = validateElicitationInput( 326 parseResult.value, 327 schema, 328 ) 329 if (validatedParsed.isValid) { 330 return validatedParsed 331 } 332 } 333 } 334 335 return syncResult 336}