Skip to content

Commit a7df15f

Browse files
committed
Implement Knowledge Base Tool Service #7504
1 parent 1d1660d commit a7df15f

2 files changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
title: Implement Knowledge Base Tool Service
3+
labels: enhancement, AI
4+
---
5+
6+
Parent epic: #7501
7+
GH ticket id: #7504
8+
9+
**Phase:** 2
10+
**Assignee:** tobiu
11+
**Status:** Done
12+
13+
## Description
14+
15+
This ticket covers the implementation of the `toolService.mjs` for the new AI Knowledge Base MCP server. The implementation will be adapted from the existing `toolService.mjs` in the `github-workflow` server.
16+
17+
The goal is to create the service that dynamically parses the `openapi.yaml` file, builds Zod schemas for validation, and maps `operationId`s to handler functions.
18+
19+
## Acceptance Criteria
20+
21+
1. The file `ai/mcp/server/knowledge-base/services/toolService.mjs` is created.
22+
2. The logic is adapted from `ai/mcp/server/github-workflow/services/toolService.mjs`.
23+
3. It correctly reads `ai/mcp/server/knowledge-base/openapi.yaml`.
24+
4. A `serviceMapping` object is created, mapping the `operationId`s from the OpenAPI spec to service functions.
25+
5. For this initial ticket, the mapped service functions can be placeholders/dummies (e.g., `() => Promise.resolve('Not implemented yet')`).
26+
6. The module exports `listTools` and `callTool` functions that are ready for integration with `mcp-stdio.mjs`.
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
import fs from 'fs';
2+
import yaml from 'js-yaml';
3+
import path from 'path';
4+
import { z } from 'zod';
5+
import { zodToJsonSchema } from 'zod-to-json-schema';
6+
import { fileURLToPath } from 'url';
7+
8+
const __filename = fileURLToPath(import.meta.url);
9+
const __dirname = path.dirname(__filename);
10+
const openApiFilePath = path.join(__dirname, '../openapi.yaml');
11+
12+
// Internal cache for parsed tool definitions to avoid re-parsing OpenAPI on every call.
13+
let toolMapping = null;
14+
// Internal cache for tools formatted for the MCP 'tools/list' response, including JSON schemas.
15+
let allToolsForListing = null;
16+
17+
/**
18+
* Maps snake_case tool names (from OpenAPI operationId) to their corresponding
19+
* service handler functions. This explicit mapping ensures clarity and allows
20+
* for easy lookup of the correct function to execute a tool.
21+
*/
22+
const serviceMapping = {
23+
healthcheck : async () => 'healthcheck not implemented',
24+
sync_database : async () => 'sync_database not implemented',
25+
delete_database: async () => 'delete_database not implemented',
26+
query_documents: async () => 'query_documents not implemented'
27+
};
28+
29+
/**
30+
* Dynamically constructs a Zod schema for a tool's input arguments based on its
31+
* OpenAPI operation definition. This schema is used for robust runtime validation
32+
* of incoming tool call arguments.
33+
* @param {object} operation - The OpenAPI operation object.
34+
* @returns {z.ZodObject} A Zod object schema representing the tool's input.
35+
*/
36+
function buildZodSchema(operation) {
37+
const shape = {};
38+
39+
// Process parameters defined in the OpenAPI operation (path, query, header, etc.).
40+
if (operation.parameters) {
41+
for (const param of operation.parameters) {
42+
let schema;
43+
switch (param.schema.type) {
44+
case 'integer':
45+
schema = z.number().int();
46+
break;
47+
case 'string':
48+
schema = z.string();
49+
break;
50+
case 'boolean':
51+
schema = z.boolean();
52+
break;
53+
default:
54+
// Fallback for unsupported or unknown schema types.
55+
schema = z.any();
56+
}
57+
// Mark schema as optional if not explicitly required.
58+
if (!param.required) {
59+
schema = schema.optional();
60+
}
61+
// Add description for better Zod schema introspection.
62+
shape[param.name] = schema.describe(param.description);
63+
}
64+
}
65+
66+
// Process request body properties, typically for POST/PUT operations.
67+
if (operation.requestBody?.content?.['application/json']?.schema) {
68+
const requestBodySchema = operation.requestBody.content['application/json'].schema;
69+
if (requestBodySchema.$ref) {
70+
// If the request body uses a ref, we'll handle it at a higher level
71+
// For now, we assume simple properties here. A more robust solution
72+
// would resolve this ref and build the schema accordingly.
73+
} else if (requestBodySchema.properties) {
74+
const { properties, required = [] } = requestBodySchema;
75+
for (const [propName, propSchema] of Object.entries(properties)) {
76+
let schema;
77+
switch (propSchema.type) {
78+
case 'string':
79+
schema = z.string();
80+
break;
81+
case 'array':
82+
schema = z.array(z.string());
83+
break;
84+
default:
85+
schema = z.any();
86+
}
87+
if (!required.includes(propName)) {
88+
schema = schema.optional();
89+
}
90+
shape[propName] = schema.describe(propSchema.description);
91+
}
92+
}
93+
}
94+
return z.object(shape);
95+
}
96+
97+
/**
98+
* Recursively resolves JSON references ($ref) within the OpenAPI document.
99+
* This is crucial for building complete Zod schemas from potentially fragmented
100+
* OpenAPI definitions (e.g., schemas defined in 'components').
101+
* @param {object} doc - The full OpenAPI document.
102+
* @param {string} ref - The JSON reference string (e.g., '#/components/schemas/MySchema').
103+
* @returns {object} The resolved schema object.
104+
*/
105+
function resolveRef(doc, ref) {
106+
// Remove '#/' prefix and split the path into components.
107+
const parts = ref.substring(2).split('/');
108+
// Traverse the document object to find the referenced schema.
109+
return parts.reduce((acc, part) => acc[part], doc);
110+
}
111+
112+
/**
113+
* Recursively builds a Zod schema from an OpenAPI schema object, handling
114+
* nested structures and JSON references. This is used for output schemas.
115+
* @param {object} doc - The full OpenAPI document for reference resolution.
116+
* @param {object} schema - The OpenAPI schema object (or a resolved reference).
117+
* @returns {z.ZodType} A Zod schema representing the OpenAPI schema.
118+
*/
119+
function buildZodSchemaFromResponse(doc, schema) {
120+
if (schema.$ref) {
121+
return buildZodSchemaFromResponse(doc, resolveRef(doc, schema.$ref));
122+
}
123+
124+
let zodSchema;
125+
if (schema.type === 'object') {
126+
const shape = {};
127+
if (schema.properties) {
128+
for (const [propName, propSchema] of Object.entries(schema.properties)) {
129+
shape[propName] = buildZodSchemaFromResponse(doc, propSchema);
130+
}
131+
}
132+
zodSchema = z.object(shape);
133+
} else if (schema.type === 'array') {
134+
zodSchema = z.array(buildZodSchemaFromResponse(doc, schema.items));
135+
} else if (schema.type === 'string') {
136+
zodSchema = z.string();
137+
} else if (schema.type === 'integer') {
138+
zodSchema = z.number().int();
139+
} else if (schema.type === 'boolean') {
140+
zodSchema = z.boolean();
141+
} else {
142+
zodSchema = z.any();
143+
}
144+
145+
if (schema.description) {
146+
zodSchema = zodSchema.describe(schema.description);
147+
}
148+
149+
return zodSchema;
150+
}
151+
152+
/**
153+
* Constructs a Zod schema for a tool's output based on its OpenAPI operation's
154+
* successful response (200, 201, or 202). This schema is used to describe the expected
155+
* output structure to clients.
156+
* @param {object} doc - The full OpenAPI document for reference resolution.
157+
* @param {object} operation - The OpenAPI operation object.
158+
* @returns {z.ZodType|null} A Zod schema for the output, or null if no schema is defined.
159+
*/
160+
function buildOutputZodSchema(doc, operation) {
161+
const response = operation.responses?.['200'] || operation.responses?.['201'] || operation.responses?.['202'];
162+
const schema = response?.content?.['application/json']?.schema;
163+
164+
if (schema) {
165+
return buildZodSchemaFromResponse(doc, schema);
166+
}
167+
168+
if (response?.content?.['text/plain']) {
169+
// For text/plain, we need to wrap it in an object for client compatibility
170+
return z.object({ result: z.string().describe(response.description || '') }).required();
171+
}
172+
173+
// If no schema is found, return null to indicate its absence.
174+
return null;
175+
}
176+
177+
/**
178+
* Initializes the internal tool mapping and the list of tools for client discovery.
179+
* This function is designed to be called lazily on the first request to avoid
180+
* unnecessary parsing at startup.
181+
*/
182+
function initializeToolMapping() {
183+
// Prevent re-initialization if already done.
184+
if (toolMapping) {
185+
return;
186+
}
187+
188+
toolMapping = {};
189+
allToolsForListing = [];
190+
191+
// Load and parse the OpenAPI specification.
192+
const openApiDocument = yaml.load(fs.readFileSync(openApiFilePath, 'utf8'));
193+
194+
// Iterate through all paths and operations defined in the OpenAPI document.
195+
for (const pathItem of Object.values(openApiDocument.paths)) {
196+
for (const operation of Object.values(pathItem)) {
197+
// Only process operations that have an operationId, as these are considered tools.
198+
if (operation.operationId) {
199+
const toolName = operation.operationId;
200+
201+
// Build Zod schema for input arguments and convert to JSON Schema for client discovery.
202+
const inputZodSchema = buildZodSchema(operation);
203+
const inputJsonSchema = zodToJsonSchema(inputZodSchema, {
204+
target: 'openApi3',
205+
$refStrategy: 'none' // Inline all definitions
206+
});
207+
208+
209+
// Build Zod schema for output and convert to JSON Schema for client discovery.
210+
const outputZodSchema = buildOutputZodSchema(openApiDocument, operation);
211+
let outputJsonSchema = null;
212+
if (outputZodSchema) {
213+
outputJsonSchema = zodToJsonSchema(outputZodSchema);
214+
}
215+
216+
// Extract argument names in order for positional argument mapping to service handlers.
217+
const argNames = (operation.parameters || []).map(p => p.name);
218+
if (operation.requestBody?.content?.['application/json']?.schema) {
219+
const requestBodySchema = operation.requestBody.content['application/json'].schema;
220+
if (requestBodySchema.$ref) {
221+
const resolvedSchema = resolveRef(openApiDocument, requestBodySchema.$ref);
222+
argNames.push(...Object.keys(resolvedSchema.properties));
223+
} else if (requestBodySchema.properties) {
224+
argNames.push(...Object.keys(requestBodySchema.properties));
225+
}
226+
}
227+
228+
229+
// Store the internal tool definition for execution.
230+
const tool = {
231+
name : toolName,
232+
title : operation.summary || toolName,
233+
description: operation.description || operation.summary,
234+
zodSchema : inputZodSchema,
235+
argNames,
236+
handler : serviceMapping[toolName]
237+
};
238+
toolMapping[toolName] = tool;
239+
240+
// Store the client-facing tool definition for 'tools/list' response.
241+
const toolForListing = {
242+
name : tool.name,
243+
title : tool.title,
244+
description : tool.description,
245+
inputSchema : inputJsonSchema
246+
};
247+
if (outputJsonSchema !== null) {
248+
toolForListing.outputSchema = outputJsonSchema;
249+
}
250+
if (operation['x-annotations'] !== null) {
251+
toolForListing.annotations = operation['x-annotations'];
252+
}
253+
allToolsForListing.push(toolForListing);
254+
}
255+
}
256+
}
257+
}
258+
259+
/**
260+
* Provides a paginated list of available tools, formatted for MCP client discovery.
261+
* @param {object} [options] - Pagination options.
262+
* @param {number} [options.cursor=0] - The starting index for the list.
263+
* @param {number} [options.limit] - The maximum number of tools to return. If not provided, all tools are returned.
264+
* @returns {object} An object containing the list of tools and a nextCursor for pagination.
265+
*/
266+
function listTools({ cursor = 0, limit } = {}) {
267+
initializeToolMapping();
268+
269+
// If no limit is specified, return all tools without pagination.
270+
if (!limit) {
271+
return {
272+
tools : allToolsForListing,
273+
nextCursor: null
274+
};
275+
}
276+
277+
// Apply pagination based on cursor and limit.
278+
const start = cursor;
279+
const end = start + limit;
280+
const toolsSlice = allToolsForListing.slice(start, end);
281+
const nextCursor = end < allToolsForListing.length ? String(end) : null; // Specs do not accept numbers
282+
283+
return {
284+
tools: toolsSlice,
285+
nextCursor
286+
};
287+
}
288+
289+
/**
290+
* Executes a specific tool with the given arguments.
291+
* This function performs input validation and maps arguments to the service handler.
292+
* @param {string} toolName - The name of the tool to call (snake_case).
293+
* @param {object} args - The arguments provided by the client for the tool call.
294+
* @returns {Promise<any>} The result of the tool execution.
295+
* @throws {Error} If the tool is not found, not implemented, or arguments are invalid.
296+
*/
297+
async function callTool(toolName, args) {
298+
initializeToolMapping();
299+
const tool = toolMapping[toolName];
300+
301+
// Ensure the tool exists and has a registered handler.
302+
if (!tool || !tool.handler) {
303+
throw new Error(`Tool "${toolName}" not found or not implemented.`);
304+
}
305+
306+
// Validate incoming arguments against the tool's Zod schema.
307+
// This will throw an error if validation fails, which is caught by the MCP server.
308+
const validatedArgs = tool.zodSchema.parse(args);
309+
310+
// Map validated arguments to positional arguments for the handler.
311+
const handlerArgs = tool.argNames.map(name => validatedArgs[name]);
312+
return tool.handler(...handlerArgs);
313+
}
314+
315+
export {
316+
listTools,
317+
callTool
318+
};

0 commit comments

Comments
 (0)