Skip to content

Commit 4001703

Browse files
authored
Merge pull request #36 from goneri/goneri/log-and-display-problems-for-each-tool_27440
log and display problems for each tool
2 parents 6afa7db + 4108dbc commit 4001703

File tree

13 files changed

+975
-67
lines changed

13 files changed

+975
-67
lines changed

src/extract-tools.ts

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
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

Comments
 (0)