|
| 1 | +/* |
| 2 | + * Local fork of extractToolsFromApi from openapi-mcp-generator |
| 3 | + * This allows us to customize the tool extraction logic for AAP specific need |
| 4 | + * License: MIT |
| 5 | + * See: https://github.com/harsha-iiiv/openapi-mcp-generator |
| 6 | + */ |
| 7 | +import { OpenAPIV3 } from 'openapi-types'; |
| 8 | +import type { JSONSchema7 } from 'json-schema'; |
| 9 | +import type { McpToolDefinition } from 'openapi-mcp-generator'; |
| 10 | + |
| 11 | + |
| 12 | +export interface McpToolLogEntry { |
| 13 | + severity: "INFO" | "WARN" | "ERR", |
| 14 | + msg: string |
| 15 | +} |
| 16 | + |
| 17 | +export interface AAPMcpToolDefinition extends McpToolDefinition { |
| 18 | + logs: McpToolLogEntry[]; |
| 19 | + size?: number; |
| 20 | +} |
| 21 | + |
| 22 | +/** |
| 23 | + * Normalize a value to boolean if it looks like a boolean; otherwise undefined. |
| 24 | + */ |
| 25 | +function normalizeBoolean(value: any): boolean | undefined { |
| 26 | + if (typeof value === 'boolean') |
| 27 | + return value; |
| 28 | + if (typeof value === 'string') { |
| 29 | + const normalized = value.trim().toLowerCase(); |
| 30 | + if (['true', '1', 'yes', 'on'].includes(normalized)) |
| 31 | + return true; |
| 32 | + if (['false', '0', 'no', 'off'].includes(normalized)) |
| 33 | + return false; |
| 34 | + return undefined; |
| 35 | + } |
| 36 | + return undefined; |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * Determine if an operation should be included in MCP generation based on x-mcp. |
| 41 | + * Precedence: operation > path > root; uses provided default when all undefined. |
| 42 | + */ |
| 43 | +function shouldIncludeOperationForMcp( |
| 44 | + api: OpenAPIV3.Document, |
| 45 | + pathItem: OpenAPIV3.PathItemObject, |
| 46 | + operation: OpenAPIV3.OperationObject, |
| 47 | + defaultInclude = true |
| 48 | +): boolean { |
| 49 | + const opRaw = (operation as any)['x-mcp']; |
| 50 | + const opVal = normalizeBoolean(opRaw); |
| 51 | + if (typeof opVal !== 'undefined') |
| 52 | + return opVal; |
| 53 | + if (typeof opRaw !== 'undefined') { |
| 54 | + console.warn(`Invalid x-mcp value on operation '${operation.operationId ?? '[no operationId]'}':`, opRaw, `-> expected boolean or 'true'/'false'. Falling back to path/root/default.`); |
| 55 | + } |
| 56 | + const pathRaw = (pathItem as any)['x-mcp']; |
| 57 | + const pathVal = normalizeBoolean(pathRaw); |
| 58 | + if (typeof pathVal !== 'undefined') |
| 59 | + return pathVal; |
| 60 | + if (typeof pathRaw !== 'undefined') { |
| 61 | + console.warn(`Invalid x-mcp value on path item:`, pathRaw, `-> expected boolean or 'true'/'false'. Falling back to root/default.`); |
| 62 | + } |
| 63 | + const rootRaw = (api as any)['x-mcp']; |
| 64 | + const rootVal = normalizeBoolean(rootRaw); |
| 65 | + if (typeof rootVal !== 'undefined') |
| 66 | + return rootVal; |
| 67 | + if (typeof rootRaw !== 'undefined') { |
| 68 | + console.warn(`Invalid x-mcp value at API root:`, rootRaw, `-> expected boolean or 'true'/'false'. Falling back to defaultInclude=${defaultInclude}.`); |
| 69 | + } |
| 70 | + return defaultInclude; |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Converts a string to TitleCase for operation ID generation |
| 75 | + */ |
| 76 | +function titleCase(str: string): string { |
| 77 | + // Converts snake_case, kebab-case, or path/parts to TitleCase |
| 78 | + return str |
| 79 | + .toLowerCase() |
| 80 | + .replace(/[-_\/](.)/g, (_, char) => char.toUpperCase()) // Handle separators |
| 81 | + .replace(/^{/, '') // Remove leading { from path params |
| 82 | + .replace(/}$/, '') // Remove trailing } from path params |
| 83 | + .replace(/^./, (char) => char.toUpperCase()); // Capitalize first letter |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Generates an operation ID from method and path |
| 88 | + */ |
| 89 | +function generateOperationId(method: string, path: string): string { |
| 90 | + // Generator: get /users/{userId}/posts -> GetUsersPostsByUserId |
| 91 | + const parts = path.split('/').filter((p) => p); // Split and remove empty parts |
| 92 | + let name = method.toLowerCase(); // Start with method name |
| 93 | + parts.forEach((part, index) => { |
| 94 | + if (part.startsWith('{') && part.endsWith('}')) { |
| 95 | + // Append 'By' + ParamName only for the *last* path parameter segment |
| 96 | + if (index === parts.length - 1) { |
| 97 | + name += 'By' + titleCase(part); |
| 98 | + } |
| 99 | + // Potentially include non-terminal params differently if needed, e.g.: |
| 100 | + // else { name += 'With' + titleCase(part); } |
| 101 | + } |
| 102 | + else { |
| 103 | + // Append the static path part in TitleCase |
| 104 | + name += titleCase(part); |
| 105 | + } |
| 106 | + }); |
| 107 | + return name; |
| 108 | +} |
| 109 | + |
| 110 | +/** |
| 111 | + * Maps an OpenAPI schema to a JSON Schema with cycle protection. |
| 112 | + */ |
| 113 | +export function mapOpenApiSchemaToJsonSchema( |
| 114 | + schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, |
| 115 | + seen = new WeakSet<object>() |
| 116 | +): JSONSchema7 | boolean { |
| 117 | + // Handle reference objects |
| 118 | + if ('$ref' in schema) { |
| 119 | + console.warn(`Unresolved $ref '${schema.$ref}'.`); |
| 120 | + return { type: 'object' }; |
| 121 | + } |
| 122 | + // Handle boolean schemas |
| 123 | + if (typeof schema === 'boolean') |
| 124 | + return schema; |
| 125 | + // Detect cycles |
| 126 | + if (seen.has(schema)) { |
| 127 | + console.warn(`Cycle detected in schema${(schema as any).title ? ` "${(schema as any).title}"` : ''}, returning generic object to break recursion.`); |
| 128 | + return { type: 'object' }; |
| 129 | + } |
| 130 | + seen.add(schema); |
| 131 | + try { |
| 132 | + // Create a copy of the schema to modify |
| 133 | + const jsonSchema: any = { ...schema }; |
| 134 | + // Convert integer type to number (JSON Schema compatible) |
| 135 | + if (schema.type === 'integer') |
| 136 | + jsonSchema.type = 'number'; |
| 137 | + // Remove OpenAPI-specific properties that aren't in JSON Schema |
| 138 | + delete jsonSchema.nullable; |
| 139 | + delete jsonSchema.example; |
| 140 | + delete jsonSchema.xml; |
| 141 | + delete jsonSchema.externalDocs; |
| 142 | + delete jsonSchema.deprecated; |
| 143 | + delete jsonSchema.readOnly; |
| 144 | + delete jsonSchema.writeOnly; |
| 145 | + // Handle nullable properties by adding null to the type |
| 146 | + if ((schema as any).nullable) { |
| 147 | + if (Array.isArray(jsonSchema.type)) { |
| 148 | + if (!jsonSchema.type.includes('null')) |
| 149 | + jsonSchema.type.push('null'); |
| 150 | + } |
| 151 | + else if (typeof jsonSchema.type === 'string') { |
| 152 | + jsonSchema.type = [jsonSchema.type, 'null']; |
| 153 | + } |
| 154 | + else if (!jsonSchema.type) { |
| 155 | + jsonSchema.type = 'null'; |
| 156 | + } |
| 157 | + } |
| 158 | + // Recursively process object properties |
| 159 | + if (jsonSchema.type === 'object' && jsonSchema.properties) { |
| 160 | + const mappedProps: any = {}; |
| 161 | + for (const [key, propSchema] of Object.entries(jsonSchema.properties)) { |
| 162 | + if (typeof propSchema === 'object' && propSchema !== null) { |
| 163 | + mappedProps[key] = mapOpenApiSchemaToJsonSchema(propSchema as any, seen); |
| 164 | + } |
| 165 | + else if (typeof propSchema === 'boolean') { |
| 166 | + mappedProps[key] = propSchema; |
| 167 | + } |
| 168 | + } |
| 169 | + jsonSchema.properties = mappedProps; |
| 170 | + } |
| 171 | + // Recursively process array items |
| 172 | + if (jsonSchema.type === 'array' && |
| 173 | + typeof jsonSchema.items === 'object' && |
| 174 | + jsonSchema.items !== null) { |
| 175 | + jsonSchema.items = mapOpenApiSchemaToJsonSchema(jsonSchema.items as any, seen); |
| 176 | + } |
| 177 | + return jsonSchema; |
| 178 | + } |
| 179 | + finally { |
| 180 | + seen.delete(schema); |
| 181 | + } |
| 182 | +} |
| 183 | + |
| 184 | +/** |
| 185 | + * Generates input schema and extracts parameter details from an operation |
| 186 | + */ |
| 187 | +export function generateInputSchemaAndDetails( |
| 188 | + operation: OpenAPIV3.OperationObject, |
| 189 | + pathParameters?: (OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject)[] |
| 190 | +): { |
| 191 | + inputSchema: JSONSchema7 | boolean; |
| 192 | + parameters: OpenAPIV3.ParameterObject[]; |
| 193 | + requestBodyContentType?: string; |
| 194 | +} { |
| 195 | + const properties: { [key: string]: JSONSchema7 | boolean } = {}; |
| 196 | + const required: string[] = []; |
| 197 | + |
| 198 | + // Process parameters - merge path parameters with operation parameters |
| 199 | + const operationParameters: OpenAPIV3.ParameterObject[] = Array.isArray(operation.parameters) |
| 200 | + ? operation.parameters.map((p) => p as OpenAPIV3.ParameterObject) |
| 201 | + : []; |
| 202 | + |
| 203 | + const pathParametersResolved: OpenAPIV3.ParameterObject[] = Array.isArray(pathParameters) |
| 204 | + ? pathParameters.map((p) => p as OpenAPIV3.ParameterObject) |
| 205 | + : []; |
| 206 | + |
| 207 | + // Combine path parameters and operation parameters |
| 208 | + // Operation parameters override path parameters if they have the same name/location |
| 209 | + const allParameters: OpenAPIV3.ParameterObject[] = []; |
| 210 | + |
| 211 | + operationParameters.concat(pathParametersResolved).forEach(opParam => { |
| 212 | + const existingIndex = allParameters.findIndex( |
| 213 | + pathParam => pathParam.name === opParam.name && pathParam.in === opParam.in |
| 214 | + ); |
| 215 | + if (existingIndex >= 0) { |
| 216 | + // Override path parameter with operation parameter |
| 217 | + allParameters[existingIndex] = opParam; |
| 218 | + } else { |
| 219 | + // Add new operation parameter |
| 220 | + allParameters.push(opParam); |
| 221 | + } |
| 222 | + }); |
| 223 | + |
| 224 | + allParameters.forEach((param) => { |
| 225 | + if (!param.name || !param.schema) return; |
| 226 | + |
| 227 | + const paramSchema = mapOpenApiSchemaToJsonSchema(param.schema as OpenAPIV3.SchemaObject); |
| 228 | + if (typeof paramSchema === 'object') { |
| 229 | + paramSchema.description = param.description || paramSchema.description; |
| 230 | + } |
| 231 | + |
| 232 | + properties[param.name] = paramSchema; |
| 233 | + if (param.required) required.push(param.name); |
| 234 | + }); |
| 235 | + |
| 236 | + // Process request body (if present) |
| 237 | + let requestBodyContentType: string | undefined = undefined; |
| 238 | + |
| 239 | + if (operation.requestBody) { |
| 240 | + const opRequestBody = operation.requestBody as OpenAPIV3.RequestBodyObject; |
| 241 | + const jsonContent = opRequestBody.content?.['application/json']; |
| 242 | + const firstContent = opRequestBody.content |
| 243 | + ? Object.entries(opRequestBody.content)[0] |
| 244 | + : undefined; |
| 245 | + |
| 246 | + if (jsonContent?.schema) { |
| 247 | + requestBodyContentType = 'application/json'; |
| 248 | + const bodySchema = mapOpenApiSchemaToJsonSchema(jsonContent.schema as OpenAPIV3.SchemaObject); |
| 249 | + |
| 250 | + if (typeof bodySchema === 'object') { |
| 251 | + bodySchema.description = |
| 252 | + opRequestBody.description || bodySchema.description || 'The JSON request body.'; |
| 253 | + } |
| 254 | + |
| 255 | + properties['requestBody'] = bodySchema; |
| 256 | + if (opRequestBody.required) required.push('requestBody'); |
| 257 | + } else if (firstContent) { |
| 258 | + const [contentType] = firstContent; |
| 259 | + requestBodyContentType = contentType; |
| 260 | + |
| 261 | + properties['requestBody'] = { |
| 262 | + type: 'string', |
| 263 | + description: opRequestBody.description || `Request body (content type: ${contentType})`, |
| 264 | + }; |
| 265 | + |
| 266 | + if (opRequestBody.required) required.push('requestBody'); |
| 267 | + } |
| 268 | + } |
| 269 | + |
| 270 | + // Combine everything into a JSON Schema |
| 271 | + const inputSchema: JSONSchema7 = { |
| 272 | + type: 'object', |
| 273 | + properties, |
| 274 | + ...(required.length > 0 && { required }), |
| 275 | + }; |
| 276 | + |
| 277 | + return { inputSchema, parameters: allParameters, requestBodyContentType }; |
| 278 | +} |
| 279 | + |
| 280 | +/** |
| 281 | + * Extracts tool definitions from an OpenAPI document |
| 282 | + * This is a local fork of the extractToolsFromApi function from openapi-mcp-generator |
| 283 | + * |
| 284 | + * @param api OpenAPI document |
| 285 | + * @param defaultInclude Whether to include operations by default when x-mcp is not specified |
| 286 | + * @returns Array of MCP tool definitions |
| 287 | + */ |
| 288 | +export function extractToolsFromApi(api: OpenAPIV3.Document, defaultInclude = true): McpToolDefinition[] { |
| 289 | + const tools: McpToolDefinition[] = []; |
| 290 | + const usedNames = new Set<string>(); |
| 291 | + const globalSecurity = api.security || []; |
| 292 | + |
| 293 | + if (!api.paths) |
| 294 | + return tools; |
| 295 | + |
| 296 | + for (const [path, pathItem] of Object.entries(api.paths)) { |
| 297 | + if (!pathItem) |
| 298 | + continue; |
| 299 | + |
| 300 | + for (const method of Object.values(OpenAPIV3.HttpMethods)) { |
| 301 | + const operation = pathItem[method]; |
| 302 | + const logs: McpToolLogEntry[] = []; |
| 303 | + if (!operation) |
| 304 | + continue; |
| 305 | + |
| 306 | + // Apply x-mcp filtering, precedence: operation > path > root |
| 307 | + try { |
| 308 | + if (!shouldIncludeOperationForMcp(api, pathItem, operation, defaultInclude)) { |
| 309 | + continue; |
| 310 | + } |
| 311 | + } |
| 312 | + catch (error) { |
| 313 | + const loc = operation.operationId || `${method} ${path}`; |
| 314 | + const extVal = (operation as any)['x-mcp'] ?? (pathItem as any)['x-mcp'] ?? (api as any)['x-mcp']; |
| 315 | + let extPreview: string; |
| 316 | + try { |
| 317 | + extPreview = JSON.stringify(extVal); |
| 318 | + } |
| 319 | + catch { |
| 320 | + extPreview = String(extVal); |
| 321 | + } |
| 322 | + console.warn(`Error evaluating x-mcp extension for operation ${loc} (x-mcp=${extPreview}):`, error); |
| 323 | + if (!defaultInclude) { |
| 324 | + continue; |
| 325 | + } |
| 326 | + } |
| 327 | + |
| 328 | + if (!operation.operationId) { |
| 329 | + logs.push({ severity: "WARN", msg: "no operationId key available" }) |
| 330 | + } |
| 331 | + |
| 332 | + // Generate a unique name for the tool |
| 333 | + let originalBaseName = operation.operationId || generateOperationId(method, path); |
| 334 | + if (!originalBaseName) |
| 335 | + continue; |
| 336 | + |
| 337 | + // Sanitize the name to be MCP-compatible (only a-z, 0-9, _, -) |
| 338 | + let nameCandidate = originalBaseName.replace(/\./g, '_').replace(/[^a-z0-9_-]/gi, '_'); |
| 339 | + let counter = 1; |
| 340 | + while (usedNames.has(nameCandidate)) { |
| 341 | + nameCandidate = `${nameCandidate}_${counter++}`; |
| 342 | + } |
| 343 | + if (originalBaseName != nameCandidate) { |
| 344 | + logs.push({ severity: "WARN", msg: `name was transformed from ${originalBaseName}` }) |
| 345 | + } |
| 346 | + usedNames.add(nameCandidate); |
| 347 | + |
| 348 | + |
| 349 | + if (!operation.description) { |
| 350 | + logs.push({ severity: "WARN", msg: "no description in OpenAPI schema" }) |
| 351 | + } |
| 352 | + if (!operation.summary) { |
| 353 | + logs.push({ severity: "INFO", msg: "no summary in OpenAPI schema" }) |
| 354 | + } |
| 355 | + |
| 356 | + // Get or create a description |
| 357 | + const description = operation.description || operation.summary || `Executes ${method.toUpperCase()} ${path}`; |
| 358 | + |
| 359 | + // Generate input schema and extract parameters |
| 360 | + const { inputSchema, parameters, requestBodyContentType } = |
| 361 | + generateInputSchemaAndDetails(operation, pathItem.parameters); |
| 362 | + |
| 363 | + // Extract parameter details for execution |
| 364 | + const executionParameters = parameters.map((p) => ({ name: p.name, in: p.in })); |
| 365 | + |
| 366 | + // Determine security requirements |
| 367 | + const securityRequirements = operation.security === null ? globalSecurity : operation.security || globalSecurity; |
| 368 | + |
| 369 | + // Create the tool definition |
| 370 | + tools.push({ |
| 371 | + name: nameCandidate, |
| 372 | + description, |
| 373 | + inputSchema, |
| 374 | + method, |
| 375 | + pathTemplate: path, |
| 376 | + parameters, |
| 377 | + executionParameters, |
| 378 | + requestBodyContentType, |
| 379 | + securityRequirements, |
| 380 | + operationId: originalBaseName, |
| 381 | + logs: logs, |
| 382 | + } as AAPMcpToolDefinition); |
| 383 | + } |
| 384 | + } |
| 385 | + return tools; |
| 386 | +} |
0 commit comments