Skip to content

Commit af7afb2

Browse files
committed
#7477 toolService.mjs: intent-driven comments
1 parent 3db26e1 commit af7afb2

1 file changed

Lines changed: 101 additions & 2 deletions

File tree

ai/mcp/server/github-workflow/services/toolService.mjs

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ const __filename = fileURLToPath(import.meta.url);
1212
const __dirname = path.dirname(__filename);
1313
const openApiFilePath = path.join(__dirname, '../openapi.yaml');
1414

15+
// Internal cache for parsed tool definitions to avoid re-parsing OpenAPI on every call.
1516
let toolMapping = null;
17+
// Internal cache for tools formatted for the MCP 'tools/list' response, including JSON schemas.
1618
let allToolsForListing = null;
1719

20+
/**
21+
* Maps snake_case tool names (from OpenAPI operationId) to their corresponding
22+
* service handler functions. This explicit mapping ensures clarity and allows
23+
* for easy lookup of the correct function to execute a tool.
24+
*/
1825
const serviceMapping = {
1926
add_labels : issueService.addLabels,
2027
checkout_pull_request: pullRequestService.checkoutPullRequest,
@@ -26,8 +33,17 @@ const serviceMapping = {
2633
remove_labels : issueService.removeLabels
2734
};
2835

36+
/**
37+
* Dynamically constructs a Zod schema for a tool's input arguments based on its
38+
* OpenAPI operation definition. This schema is used for robust runtime validation
39+
* of incoming tool call arguments.
40+
* @param {object} operation - The OpenAPI operation object.
41+
* @returns {z.ZodObject} A Zod object schema representing the tool's input.
42+
*/
2943
function buildZodSchema(operation) {
3044
const shape = {};
45+
46+
// Process parameters defined in the OpenAPI operation (path, query, header, etc.).
3147
if (operation.parameters) {
3248
for (const param of operation.parameters) {
3349
let schema;
@@ -42,15 +58,19 @@ function buildZodSchema(operation) {
4258
schema = z.boolean();
4359
break;
4460
default:
61+
// Fallback for unsupported or unknown schema types.
4562
schema = z.any();
4663
}
64+
// Mark schema as optional if not explicitly required.
4765
if (!param.required) {
4866
schema = schema.optional();
4967
}
68+
// Add description for better Zod schema introspection.
5069
shape[param.name] = schema.describe(param.description);
5170
}
5271
}
5372

73+
// Process request body properties, typically for POST/PUT operations.
5474
if (operation.requestBody?.content?.['application/json']?.schema?.properties) {
5575
const { properties, required = [] } = operation.requestBody.content['application/json'].schema;
5676
for (const [propName, propSchema] of Object.entries(properties)) {
@@ -60,30 +80,55 @@ function buildZodSchema(operation) {
6080
schema = z.string();
6181
break;
6282
case 'array':
63-
schema = z.array(z.string()); // Assuming array of strings for now
83+
// Currently assumes arrays of strings. This is a simplification
84+
// based on current OpenAPI spec usage and may need refinement
85+
// if other array item types are introduced.
86+
schema = z.array(z.string());
6487
break;
6588
default:
89+
// Fallback for unsupported or unknown schema types.
6690
schema = z.any();
6791
}
92+
// Mark schema as optional if not explicitly required in the request body.
6893
if (!required.includes(propName)) {
6994
schema = schema.optional();
7095
}
96+
// Add description for better Zod schema introspection.
7197
shape[propName] = schema.describe(propSchema.description);
7298
}
7399
}
74100
return z.object(shape);
75101
}
76102

103+
/**
104+
* Recursively resolves JSON references ($ref) within the OpenAPI document.
105+
* This is crucial for building complete Zod schemas from potentially fragmented
106+
* OpenAPI definitions (e.g., schemas defined in 'components').
107+
* @param {object} doc - The full OpenAPI document.
108+
* @param {string} ref - The JSON reference string (e.g., '#/components/schemas/MySchema').
109+
* @returns {object} The resolved schema object.
110+
*/
77111
function resolveRef(doc, ref) {
78-
const parts = ref.substring(2).split('/'); // remove '#/' and split
112+
// Remove '#/' prefix and split the path into components.
113+
const parts = ref.substring(2).split('/');
114+
// Traverse the document object to find the referenced schema.
79115
return parts.reduce((acc, part) => acc[part], doc);
80116
}
81117

118+
/**
119+
* Recursively builds a Zod schema from an OpenAPI schema object, handling
120+
* nested structures and JSON references. This is used for output schemas.
121+
* @param {object} doc - The full OpenAPI document for reference resolution.
122+
* @param {object} schema - The OpenAPI schema object (or a resolved reference).
123+
* @returns {z.ZodType} A Zod schema representing the OpenAPI schema.
124+
*/
82125
function buildZodSchemaFromResponse(doc, schema) {
126+
// If the schema is a reference, resolve it first.
83127
if (schema.$ref) {
84128
return buildZodSchemaFromResponse(doc, resolveRef(doc, schema.$ref));
85129
}
86130

131+
// Handle different OpenAPI schema types.
87132
if (schema.type === 'object') {
88133
const shape = {};
89134
if (schema.properties) {
@@ -93,6 +138,7 @@ function buildZodSchemaFromResponse(doc, schema) {
93138
}
94139
return z.object(shape);
95140
} else if (schema.type === 'array') {
141+
// Recursively build schema for array items.
96142
return z.array(buildZodSchemaFromResponse(doc, schema.items));
97143
} else if (schema.type === 'string') {
98144
return z.string();
@@ -101,53 +147,81 @@ function buildZodSchemaFromResponse(doc, schema) {
101147
} else if (schema.type === 'boolean') {
102148
return z.boolean();
103149
} else {
150+
// Fallback for unsupported or unknown schema types.
104151
return z.any();
105152
}
106153
}
107154

155+
/**
156+
* Constructs a Zod schema for a tool's output based on its OpenAPI operation's
157+
* successful response (200 or 201). This schema is used to describe the expected
158+
* output structure to clients.
159+
* @param {object} doc - The full OpenAPI document for reference resolution.
160+
* @param {object} operation - The OpenAPI operation object.
161+
* @returns {z.ZodType|null} A Zod schema for the output, or null if no schema is defined.
162+
*/
108163
function buildOutputZodSchema(doc, operation) {
164+
// Prioritize 200 OK, then 201 Created responses.
109165
const response = operation.responses?.['200'] || operation.responses?.['201'];
110166
const schema = response?.content?.['application/json']?.schema;
111167

168+
// If an application/json schema is found, build a Zod schema from it.
112169
if (schema) {
113170
return buildZodSchemaFromResponse(doc, schema);
114171
}
115172

173+
// Special handling for text/plain responses (e.g., diff output).
116174
if (response?.content?.['text/plain']) {
117175
return z.string();
118176
}
119177

120178
return null;
121179
}
122180

181+
/**
182+
* Initializes the internal tool mapping and the list of tools for client discovery.
183+
* This function is designed to be called lazily on the first request to avoid
184+
* unnecessary parsing at startup.
185+
*/
123186
function initializeToolMapping() {
187+
// Prevent re-initialization if already done.
124188
if (toolMapping) {
125189
return;
126190
}
127191

128192
toolMapping = {};
129193
allToolsForListing = [];
130194

195+
// Load and parse the OpenAPI specification.
131196
const openApiDocument = yaml.load(fs.readFileSync(openApiFilePath, 'utf8'));
132197

198+
// Iterate through all paths and operations defined in the OpenAPI document.
133199
for (const pathItem of Object.values(openApiDocument.paths)) {
134200
for (const operation of Object.values(pathItem)) {
201+
// Only process operations that have an operationId, as these are considered tools.
135202
if (operation.operationId) {
136203
const toolName = operation.operationId;
204+
205+
// Build Zod schema for input arguments and convert to JSON Schema for client discovery.
137206
const inputZodSchema = buildZodSchema(operation);
138207
const inputJsonSchema = zodToJsonSchema(inputZodSchema);
139208

209+
// Build Zod schema for output and convert to JSON Schema for client discovery.
140210
const outputZodSchema = buildOutputZodSchema(openApiDocument, operation);
141211
let outputJsonSchema = null;
142212
if (outputZodSchema) {
143213
outputJsonSchema = zodToJsonSchema(outputZodSchema);
144214
}
145215

216+
// Extract argument names in order for positional argument mapping to service handlers.
217+
// This is crucial because Zod validation returns an object, but service handlers
218+
// might expect positional arguments. The order is derived from OpenAPI parameters.
146219
const argNames = (operation.parameters || []).map(p => p.name);
147220
if (operation.requestBody?.content?.['application/json']?.schema?.properties) {
148221
argNames.push(...Object.keys(operation.requestBody.content['application/json'].schema.properties));
149222
}
150223

224+
// Store the internal tool definition for execution.
151225
const tool = {
152226
name : toolName,
153227
title : operation.summary || toolName,
@@ -158,6 +232,7 @@ function initializeToolMapping() {
158232
};
159233
toolMapping[toolName] = tool;
160234

235+
// Store the client-facing tool definition for 'tools/list' response.
161236
allToolsForListing.push({
162237
name : tool.name,
163238
title : tool.title,
@@ -171,16 +246,25 @@ function initializeToolMapping() {
171246
}
172247
}
173248

249+
/**
250+
* Provides a paginated list of available tools, formatted for MCP client discovery.
251+
* @param {object} [options] - Pagination options.
252+
* @param {number} [options.cursor=0] - The starting index for the list.
253+
* @param {number} [options.limit] - The maximum number of tools to return. If not provided, all tools are returned.
254+
* @returns {object} An object containing the list of tools and a nextCursor for pagination.
255+
*/
174256
function listTools({ cursor = 0, limit } = {}) {
175257
initializeToolMapping();
176258

259+
// If no limit is specified, return all tools without pagination.
177260
if (!limit) {
178261
return {
179262
tools : allToolsForListing,
180263
nextCursor: null
181264
};
182265
}
183266

267+
// Apply pagination based on cursor and limit.
184268
const start = cursor;
185269
const end = start + limit;
186270
const toolsSlice = allToolsForListing.slice(start, end);
@@ -192,20 +276,35 @@ function listTools({ cursor = 0, limit } = {}) {
192276
};
193277
}
194278

279+
/**
280+
* Executes a specific tool with the given arguments.
281+
* This function performs input validation and maps arguments to the service handler.
282+
* @param {string} toolName - The name of the tool to call (snake_case).
283+
* @param {object} args - The arguments provided by the client for the tool call.
284+
* @returns {Promise<any>} The result of the tool execution.
285+
* @throws {Error} If the tool is not found, not implemented, or arguments are invalid.
286+
*/
195287
async function callTool(toolName, args) {
196288
initializeToolMapping();
197289
const tool = toolMapping[toolName];
198290

291+
// Ensure the tool exists and has a registered handler.
199292
if (!tool || !tool.handler) {
200293
throw new Error(`Tool "${toolName}" not found or not implemented.`);
201294
}
202295

296+
// Validate incoming arguments against the tool's Zod schema.
297+
// This will throw an error if validation fails, which is caught by the MCP server.
203298
const validatedArgs = tool.zodSchema.parse(args);
204299

300+
// Special handling for 'list_pull_requests' as its handler expects a single object argument.
301+
// This is a pragmatic solution for an inconsistent service handler signature.
205302
if (toolName === 'list_pull_requests') {
206303
return tool.handler(validatedArgs);
207304
}
208305

306+
// For other tools, map validated arguments to positional arguments for the handler.
307+
// The order is determined by the 'argNames' extracted from OpenAPI.
209308
const handlerArgs = tool.argNames.map(name => validatedArgs[name]);
210309
return tool.handler(...handlerArgs);
211310
}

0 commit comments

Comments
 (0)