Skip to content

Commit 432761b

Browse files
committed
Refactor toolService.mjs to Reduce Code Duplication #7517
1 parent 819f7e0 commit 432761b

5 files changed

Lines changed: 342 additions & 604 deletions

File tree

.github/ISSUE/epic-architect-knowledge-base-as-mcp.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ We will employ a rapid and agile development approach. The scope and API specifi
4646
- `ticket-kb-review-and-correct-database-service.md`: Review and correct the `databaseService` implementation to ensure full feature parity.
4747
- `ticket-kb-enhance-openapi-examples.md`: Enhance the OpenAPI spec with tool usage examples from AGENTS.md.
4848
- `ticket-kb-enhance-tool-manuals.md`: Expand tool descriptions into comprehensive in-spec manuals.
49+
- `ticket-kb-refactor-tool-service.md`: Refactor `toolService.mjs` to reduce code duplication.
4950

5051
### Phase 4: Bugfixes
5152

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
title: Refactor toolService.mjs to Reduce Code Duplication
3+
labels: enhancement, AI
4+
---
5+
6+
Parent epic: #7501
7+
GH ticket id: #7517
8+
9+
**Phase:** 3
10+
**Assignee:** tobiu
11+
**Status:** To Do
12+
13+
## Description
14+
15+
This ticket addresses the code duplication between the `toolService.mjs` files in the `knowledge-base` and `github-workflow` MCP servers. The goal is to create a single, shared `toolService` that can be configured for each server, reducing redundancy and improving maintainability.
16+
17+
## Acceptance Criteria
18+
19+
1. A new, generic `toolService.mjs` is created in `ai/mcp/server/`.
20+
2. The new service is parameterized to accept a `serviceMapping` and an `openApiFilePath`.
21+
3. The `toolService.mjs` files in `knowledge-base` and `github-workflow` are refactored to use the new shared service.
22+
4. All existing functionality of both MCP servers remains unchanged and fully operational.

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

Lines changed: 2 additions & 298 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,15 @@
1-
import fs from 'fs';
2-
import yaml from 'js-yaml';
31
import path from 'path';
4-
import { z } from 'zod';
5-
import { zodToJsonSchema } from 'zod-to-json-schema';
62
import { fileURLToPath } from 'url';
73
import * as healthService from './healthService.mjs';
84
import * as issueService from './issueService.mjs';
95
import * as labelService from './labelService.mjs';
106
import * as pullRequestService from './pullRequestService.mjs';
7+
import { initialize, listTools, callTool } from '../../toolService.mjs';
118

129
const __filename = fileURLToPath(import.meta.url);
1310
const __dirname = path.dirname(__filename);
1411
const openApiFilePath = path.join(__dirname, '../openapi.yaml');
1512

16-
// Internal cache for parsed tool definitions to avoid re-parsing OpenAPI on every call.
17-
let toolMapping = null;
18-
// Internal cache for tools formatted for the MCP 'tools/list' response, including JSON schemas.
19-
let allToolsForListing = null;
20-
21-
/**
22-
* Maps snake_case tool names (from OpenAPI operationId) to their corresponding
23-
* service handler functions. This explicit mapping ensures clarity and allows
24-
* for easy lookup of the correct function to execute a tool.
25-
*/
2613
const serviceMapping = {
2714
add_labels : issueService.addLabels,
2815
checkout_pull_request: pullRequestService.checkoutPullRequest,
@@ -35,290 +22,7 @@ const serviceMapping = {
3522
remove_labels : issueService.removeLabels
3623
};
3724

38-
/**
39-
* Dynamically constructs a Zod schema for a tool's input arguments based on its
40-
* OpenAPI operation definition. This schema is used for robust runtime validation
41-
* of incoming tool call arguments.
42-
* @param {object} openApiDocument - The OpenAPI document object.
43-
* @param {object} operation - The OpenAPI operation object.
44-
* @returns {z.ZodObject} A Zod object schema representing the tool's input.
45-
*/
46-
function buildZodSchema(openApiDocument, operation) {
47-
const shape = {};
48-
49-
// Process parameters defined in the OpenAPI operation (path, query, header, etc.).
50-
if (operation.parameters) {
51-
for (const param of operation.parameters) {
52-
let schema;
53-
switch (param.schema.type) {
54-
case 'integer':
55-
schema = z.number().int();
56-
break;
57-
case 'string':
58-
schema = z.string();
59-
break;
60-
case 'boolean':
61-
schema = z.boolean();
62-
break;
63-
default:
64-
// Fallback for unsupported or unknown schema types.
65-
schema = z.any();
66-
}
67-
// Mark schema as optional if not explicitly required.
68-
if (!param.required) {
69-
schema = schema.optional();
70-
}
71-
// Add description for better Zod schema introspection.
72-
shape[param.name] = schema.describe(param.description);
73-
}
74-
}
75-
76-
// Process request body properties, typically for POST/PUT operations.
77-
if (operation.requestBody?.content?.['application/json']?.schema) {
78-
let requestBodySchema = operation.requestBody.content['application/json'].schema;
79-
if (requestBodySchema.$ref) {
80-
requestBodySchema = resolveRef(openApiDocument, requestBodySchema.$ref);
81-
}
82-
83-
if (requestBodySchema.properties) {
84-
const { properties, required = [] } = requestBodySchema;
85-
for (const [propName, propSchema] of Object.entries(properties)) {
86-
let schema;
87-
switch (propSchema.type) {
88-
case 'string':
89-
schema = z.string();
90-
break;
91-
case 'array':
92-
schema = z.array(z.string());
93-
break;
94-
default:
95-
schema = z.any();
96-
}
97-
if (!required.includes(propName)) {
98-
schema = schema.optional();
99-
}
100-
shape[propName] = schema.describe(propSchema.description);
101-
}
102-
}
103-
}
104-
return z.object(shape);
105-
}
106-
107-
/**
108-
* Recursively resolves JSON references ($ref) within the OpenAPI document.
109-
* This is crucial for building complete Zod schemas from potentially fragmented
110-
* OpenAPI definitions (e.g., schemas defined in 'components').
111-
* @param {object} doc - The full OpenAPI document.
112-
* @param {string} ref - The JSON reference string (e.g., '#/components/schemas/MySchema').
113-
* @returns {object} The resolved schema object.
114-
*/
115-
function resolveRef(doc, ref) {
116-
// Remove '#/' prefix and split the path into components.
117-
const parts = ref.substring(2).split('/');
118-
// Traverse the document object to find the referenced schema.
119-
return parts.reduce((acc, part) => acc[part], doc);
120-
}
121-
122-
/**
123-
* Recursively builds a Zod schema from an OpenAPI schema object, handling
124-
* nested structures and JSON references. This is used for output schemas.
125-
* @param {object} doc - The full OpenAPI document for reference resolution.
126-
* @param {object} schema - The OpenAPI schema object (or a resolved reference).
127-
* @returns {z.ZodType} A Zod schema representing the OpenAPI schema.
128-
*/
129-
function buildZodSchemaFromResponse(doc, schema) {
130-
if (schema.$ref) {
131-
return buildZodSchemaFromResponse(doc, resolveRef(doc, schema.$ref));
132-
}
133-
134-
let zodSchema;
135-
if (schema.type === 'object') {
136-
const shape = {};
137-
if (schema.properties) {
138-
for (const [propName, propSchema] of Object.entries(schema.properties)) {
139-
shape[propName] = buildZodSchemaFromResponse(doc, propSchema);
140-
}
141-
}
142-
zodSchema = z.object(shape);
143-
} else if (schema.type === 'array') {
144-
zodSchema = z.array(buildZodSchemaFromResponse(doc, schema.items));
145-
} else if (schema.type === 'string') {
146-
zodSchema = z.string();
147-
} else if (schema.type === 'integer') {
148-
zodSchema = z.number().int();
149-
} else if (schema.type === 'boolean') {
150-
zodSchema = z.boolean();
151-
} else {
152-
zodSchema = z.any();
153-
}
154-
155-
if (schema.description) {
156-
zodSchema = zodSchema.describe(schema.description);
157-
}
158-
159-
return zodSchema;
160-
}
161-
162-
/**
163-
* Constructs a Zod schema for a tool's output based on its OpenAPI operation's
164-
* successful response (200 or 201). This schema is used to describe the expected
165-
* output structure to clients.
166-
* @param {object} doc - The full OpenAPI document for reference resolution.
167-
* @param {object} operation - The OpenAPI operation object.
168-
* @returns {z.ZodType|null} A Zod schema for the output, or null if no schema is defined.
169-
*/
170-
function buildOutputZodSchema(doc, operation) {
171-
const response = operation.responses?.['200'] || operation.responses?.['201'];
172-
const schema = response?.content?.['application/json']?.schema;
173-
174-
if (schema) {
175-
return buildZodSchemaFromResponse(doc, schema);
176-
}
177-
178-
if (response?.content?.['text/plain']) {
179-
// For text/plain, we need to wrap it in an object for client compatibility
180-
return z.object({ result: z.string().describe(response.description || '') }).required();
181-
}
182-
183-
// If no schema is found, return null to indicate its absence.
184-
return null;
185-
}
186-
187-
/**
188-
* Initializes the internal tool mapping and the list of tools for client discovery.
189-
* This function is designed to be called lazily on the first request to avoid
190-
* unnecessary parsing at startup.
191-
*/
192-
function initializeToolMapping() {
193-
// Prevent re-initialization if already done.
194-
if (toolMapping) {
195-
return;
196-
}
197-
198-
toolMapping = {};
199-
allToolsForListing = [];
200-
201-
// Load and parse the OpenAPI specification.
202-
const openApiDocument = yaml.load(fs.readFileSync(openApiFilePath, 'utf8'));
203-
204-
// Iterate through all paths and operations defined in the OpenAPI document.
205-
for (const pathItem of Object.values(openApiDocument.paths)) {
206-
for (const operation of Object.values(pathItem)) {
207-
// Only process operations that have an operationId, as these are considered tools.
208-
if (operation.operationId) {
209-
const toolName = operation.operationId;
210-
211-
// Build Zod schema for input arguments and convert to JSON Schema for client discovery.
212-
const inputZodSchema = buildZodSchema(openApiDocument, operation);
213-
const inputJsonSchema = zodToJsonSchema(inputZodSchema);
214-
215-
// Build Zod schema for output and convert to JSON Schema for client discovery.
216-
const outputZodSchema = buildOutputZodSchema(openApiDocument, operation);
217-
let outputJsonSchema = null;
218-
if (outputZodSchema) {
219-
outputJsonSchema = zodToJsonSchema(outputZodSchema);
220-
}
221-
222-
// Extract argument names in order for positional argument mapping to service handlers.
223-
// This is crucial because Zod validation returns an object, but service handlers
224-
// might expect positional arguments. The order is derived from OpenAPI parameters.
225-
const argNames = (operation.parameters || []).map(p => p.name);
226-
if (operation.requestBody?.content?.['application/json']?.schema?.properties) {
227-
argNames.push(...Object.keys(operation.requestBody.content['application/json'].schema.properties));
228-
}
229-
230-
// Store the internal tool definition for execution.
231-
const tool = {
232-
name : toolName,
233-
title : operation.summary || toolName,
234-
description: operation.description || operation.summary,
235-
zodSchema : inputZodSchema,
236-
argNames,
237-
handler : serviceMapping[toolName]
238-
};
239-
toolMapping[toolName] = tool;
240-
241-
// Store the client-facing tool definition for 'tools/list' response.
242-
const toolForListing = {
243-
name : tool.name,
244-
title : tool.title,
245-
description : tool.description,
246-
inputSchema : inputJsonSchema
247-
};
248-
if (outputJsonSchema !== null) {
249-
toolForListing.outputSchema = outputJsonSchema;
250-
}
251-
if (operation['x-annotations'] !== null) {
252-
toolForListing.annotations = operation['x-annotations'];
253-
}
254-
allToolsForListing.push(toolForListing);
255-
}
256-
}
257-
}
258-
}
259-
260-
/**
261-
* Provides a paginated list of available tools, formatted for MCP client discovery.
262-
* @param {object} [options] - Pagination options.
263-
* @param {number} [options.cursor=0] - The starting index for the list.
264-
* @param {number} [options.limit] - The maximum number of tools to return. If not provided, all tools are returned.
265-
* @returns {object} An object containing the list of tools and a nextCursor for pagination.
266-
*/
267-
function listTools({ cursor = 0, limit } = {}) {
268-
initializeToolMapping();
269-
270-
// If no limit is specified, return all tools without pagination.
271-
if (!limit) {
272-
return {
273-
tools : allToolsForListing,
274-
nextCursor: null
275-
};
276-
}
277-
278-
// Apply pagination based on cursor and limit.
279-
const start = cursor;
280-
const end = start + limit;
281-
const toolsSlice = allToolsForListing.slice(start, end);
282-
const nextCursor = end < allToolsForListing.length ? String(end) : null; // Specs do not accept numbers
283-
284-
return {
285-
tools: toolsSlice,
286-
nextCursor
287-
};
288-
}
289-
290-
/**
291-
* Executes a specific tool with the given arguments.
292-
* This function performs input validation and maps arguments to the service handler.
293-
* @param {string} toolName - The name of the tool to call (snake_case).
294-
* @param {object} args - The arguments provided by the client for the tool call.
295-
* @returns {Promise<any>} The result of the tool execution.
296-
* @throws {Error} If the tool is not found, not implemented, or arguments are invalid.
297-
*/
298-
async function callTool(toolName, args) {
299-
initializeToolMapping();
300-
const tool = toolMapping[toolName];
301-
302-
// Ensure the tool exists and has a registered handler.
303-
if (!tool || !tool.handler) {
304-
throw new Error(`Tool "${toolName}" not found or not implemented.`);
305-
}
306-
307-
// Validate incoming arguments against the tool's Zod schema.
308-
// This will throw an error if validation fails, which is caught by the MCP server.
309-
const validatedArgs = tool.zodSchema.parse(args);
310-
311-
// Special handling for 'list_pull_requests' as its handler expects a single object argument.
312-
// This is a pragmatic solution for an inconsistent service handler signature.
313-
if (toolName === 'list_pull_requests') {
314-
return tool.handler(validatedArgs);
315-
}
316-
317-
// For other tools, map validated arguments to positional arguments for the handler.
318-
// The order is determined by the 'argNames' extracted from OpenAPI.
319-
const handlerArgs = tool.argNames.map(name => validatedArgs[name]);
320-
return tool.handler(...handlerArgs);
321-
}
25+
initialize(serviceMapping, openApiFilePath);
32226

32327
export {
32428
listTools,

0 commit comments

Comments
 (0)