From 78f2263c8674caf0e353a869ae74b845b5c897d4 Mon Sep 17 00:00:00 2001 From: bipproduction Date: Thu, 13 Nov 2025 19:37:09 +0800 Subject: [PATCH] fix mcp generator --- src/server/lib/mcp_tool_convert.ts | 428 ++++++++++++++++++++++++---- src/server/lib/mcp_tool_convert.txt | 108 +++++++ src/server/routes/test.ts | 6 +- 3 files changed, 477 insertions(+), 65 deletions(-) create mode 100644 src/server/lib/mcp_tool_convert.txt diff --git a/src/server/lib/mcp_tool_convert.ts b/src/server/lib/mcp_tool_convert.ts index 0ddec63..363d029 100644 --- a/src/server/lib/mcp_tool_convert.ts +++ b/src/server/lib/mcp_tool_convert.ts @@ -1,5 +1,4 @@ import _ from "lodash"; -import { v4 as uuidv4 } from "uuid"; interface McpTool { name: string; @@ -16,57 +15,50 @@ interface McpTool { } /** - * Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions (without run()). - * Hanya menyertakan endpoint yang memiliki tag berisi "mcp". + * Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions. */ export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] { const tools: McpTool[] = []; + + if (!openApiJson || typeof openApiJson !== "object") { + console.warn("Invalid OpenAPI JSON"); + return tools; + } + const paths = openApiJson.paths || {}; + + if (Object.keys(paths).length === 0) { + console.warn("No paths found in OpenAPI spec"); + return tools; + } for (const [path, methods] of Object.entries(paths)) { - // ✅ skip semua path internal MCP + if (!path || typeof path !== "string") continue; if (path.startsWith("/mcp")) continue; - for (const [method, operation] of Object.entries(methods as any)) { + if (!methods || typeof methods !== "object") continue; + + for (const [method, operation] of Object.entries(methods)) { + const validMethods = ["get", "post", "put", "delete", "patch", "head", "options"]; + if (!validMethods.includes(method.toLowerCase())) continue; + + if (!operation || typeof operation !== "object") continue; + const tags: string[] = Array.isArray(operation.tags) ? operation.tags : []; - // ✅ exclude semua yang tidak punya tag atau tag-nya tidak mengandung "mcp" - if (!tags.length || !tags.some(t => t.toLowerCase().includes("mcp"))) continue; + if (!tags.length || !tags.some(t => + typeof t === "string" && t.toLowerCase().includes("mcp") + )) continue; - const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool"; - const name = cleanToolName(rawName); - - const description = - operation.description || - operation.summary || - `Execute ${method.toUpperCase()} ${path}`; - - const schema = - operation.requestBody?.content?.["application/json"]?.schema || { - type: "object", - properties: {}, - additionalProperties: true, - }; - - const tool: McpTool = { - name, - description, - "x-props": { - method: method.toUpperCase(), - path, - operationId: operation.operationId, - tag: tags[0], - deprecated: operation.deprecated || false, - summary: operation.summary, - }, - inputSchema: { - ...schema, - additionalProperties: true, - $schema: "http://json-schema.org/draft-07/schema#", - }, - }; - - tools.push(tool); + try { + const tool = createToolFromOperation(path, method, operation, tags); + if (tool) { + tools.push(tool); + } + } catch (error) { + console.error(`Error creating tool for ${method.toUpperCase()} ${path}:`, error); + continue; + } } } @@ -74,35 +66,347 @@ export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] { } /** - * Bersihkan nama agar valid untuk digunakan sebagai tool name - * - hapus karakter spesial - * - ubah slash jadi underscore - * - hilangkan prefix umum (get_, post_, api_, dll) - * - rapikan underscore berganda + * Buat MCP tool dari operation OpenAPI + */ +function createToolFromOperation( + path: string, + method: string, + operation: any, + tags: string[] +): McpTool | null { + try { + const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool"; + const name = cleanToolName(rawName); + + if (!name || name === "unnamed_tool") { + console.warn(`Invalid tool name for ${method} ${path}`); + return null; + } + + const description = + operation.description || + operation.summary || + `Execute ${method.toUpperCase()} ${path}`; + + // ✅ Extract schema berdasarkan method + let schema; + if (method.toLowerCase() === "get") { + // ✅ Untuk GET, ambil dari parameters (query/path) + schema = extractParametersSchema(operation.parameters || []); + } else { + // ✅ Untuk POST/PUT/etc, ambil dari requestBody + schema = extractRequestBodySchema(operation); + } + + const inputSchema = createInputSchema(schema); + + return { + name, + description, + "x-props": { + method: method.toUpperCase(), + path, + operationId: operation.operationId, + tag: tags[0], + deprecated: operation.deprecated || false, + summary: operation.summary, + }, + inputSchema, + }; + } catch (error) { + console.error(`Failed to create tool from operation:`, error); + return null; + } +} + +/** + * Extract schema dari parameters (untuk GET requests) + */ +function extractParametersSchema(parameters: any[]): any { + if (!Array.isArray(parameters) || parameters.length === 0) { + return null; + } + + const properties: any = {}; + const required: string[] = []; + + for (const param of parameters) { + if (!param || typeof param !== "object") continue; + + // ✅ Support path, query, dan header parameters + if (["path", "query", "header"].includes(param.in)) { + const paramName = param.name; + if (!paramName || typeof paramName !== "string") continue; + + properties[paramName] = { + type: param.schema?.type || "string", + description: param.description || `${param.in} parameter: ${paramName}`, + }; + + // ✅ Copy field tambahan dari schema + if (param.schema) { + const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"]; + for (const field of allowedFields) { + if (param.schema[field] !== undefined) { + properties[paramName][field] = param.schema[field]; + } + } + } + + if (param.required === true) { + required.push(paramName); + } + } + } + + if (Object.keys(properties).length === 0) { + return null; + } + + return { + type: "object", + properties, + required, + }; +} + +/** + * Extract schema dari requestBody (untuk POST/PUT/etc requests) + */ +function extractRequestBodySchema(operation: any): any { + if (!operation.requestBody?.content) { + return null; + } + + const content = operation.requestBody.content; + + const contentTypes = [ + "application/json", + "multipart/form-data", + "application/x-www-form-urlencoded", + "text/plain", + ]; + + for (const contentType of contentTypes) { + if (content[contentType]?.schema) { + return content[contentType].schema; + } + } + + for (const [_, value] of Object.entries(content)) { + if (value?.schema) { + return value.schema; + } + } + + return null; +} + +/** + * Buat input schema yang valid untuk MCP + */ +function createInputSchema(schema: any): any { + const defaultSchema = { + type: "object", + properties: {}, + additionalProperties: false, + }; + + if (!schema || typeof schema !== "object") { + return defaultSchema; + } + + try { + const properties: any = {}; + const required: string[] = []; + const originalRequired = Array.isArray(schema.required) ? schema.required : []; + + if (schema.properties && typeof schema.properties === "object") { + for (const [key, prop] of Object.entries(schema.properties)) { + if (!key || typeof key !== "string") continue; + + try { + const cleanProp = cleanProperty(prop); + if (cleanProp) { + properties[key] = cleanProp; + + // ✅ PERBAIKAN: Check optional flag dengan benar + const isOptional = prop?.optional === true || prop?.optional === "true"; + const isInRequired = originalRequired.includes(key); + + // ✅ Hanya masukkan ke required jika memang required DAN bukan optional + if (isInRequired && !isOptional) { + required.push(key); + } + } + } catch (error) { + console.error(`Error cleaning property ${key}:`, error); + continue; + } + } + } + + return { + type: "object", + properties, + required, + additionalProperties: false, + }; + } catch (error) { + console.error("Error creating input schema:", error); + return defaultSchema; + } +} + +/** + * Bersihkan property dari field custom + */ +function cleanProperty(prop: any): any | null { + if (!prop || typeof prop !== "object") { + return { type: "string" }; + } + + try { + const cleaned: any = { + type: prop.type || "string", + }; + + const allowedFields = [ + "description", + "examples", + "example", + "default", + "enum", + "pattern", + "minLength", + "maxLength", + "minimum", + "maximum", + "format", + "multipleOf", + "exclusiveMinimum", + "exclusiveMaximum", + ]; + + for (const field of allowedFields) { + if (prop[field] !== undefined && prop[field] !== null) { + cleaned[field] = prop[field]; + } + } + + if (prop.properties && typeof prop.properties === "object") { + cleaned.properties = {}; + for (const [key, value] of Object.entries(prop.properties)) { + const cleanedNested = cleanProperty(value); + if (cleanedNested) { + cleaned.properties[key] = cleanedNested; + } + } + + if (Array.isArray(prop.required)) { + cleaned.required = prop.required.filter((r: any) => typeof r === "string"); + } + } + + if (prop.items) { + cleaned.items = cleanProperty(prop.items); + } + + if (Array.isArray(prop.oneOf)) { + cleaned.oneOf = prop.oneOf.map(cleanProperty).filter(Boolean); + } + if (Array.isArray(prop.anyOf)) { + cleaned.anyOf = prop.anyOf.map(cleanProperty).filter(Boolean); + } + if (Array.isArray(prop.allOf)) { + cleaned.allOf = prop.allOf.map(cleanProperty).filter(Boolean); + } + + return cleaned; + } catch (error) { + console.error("Error cleaning property:", error); + return null; + } +} + +/** + * Bersihkan nama tool */ function cleanToolName(name: string): string { - return name - .replace(/[{}]/g, "") - .replace(/[^a-zA-Z0-9_]/g, "_") - .replace(/_+/g, "_") - .replace(/^_|_$/g, "") - .replace(/^(get|post|put|delete|patch|api)_/i, "") - .replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "") - .replace(/(^_|_$)/g, ""); + if (!name || typeof name !== "string") { + return "unnamed_tool"; + } + + try { + return name + .replace(/[{}]/g, "") + .replace(/[^a-zA-Z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .replace(/^(get|post|put|delete|patch|api)_/i, "") + .replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "") + .replace(/(^_|_$)/g, "") + || "unnamed_tool"; + } catch (error) { + console.error("Error cleaning tool name:", error); + return "unnamed_tool"; + } } /** * Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP */ -export async function getMcpTools() { - const data = await fetch(`${process.env.BUN_PUBLIC_BASE_URL}/docs/json`); - const openApiJson = await data.json(); - const tools = convertOpenApiToMcpTools(openApiJson); - return tools; +export async function getMcpTools(): Promise { + try { + const baseUrl = process.env.BUN_PUBLIC_BASE_URL; + + if (!baseUrl) { + throw new Error("BUN_PUBLIC_BASE_URL environment variable is not set"); + } + + const url = `${baseUrl}/docs/json`; + console.log(`Fetching OpenAPI spec from: ${url}`); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const openApiJson = await response.json(); + const tools = convertOpenApiToMcpTools(openApiJson); + + console.log(`✅ Successfully generated ${tools.length} MCP tools`); + + return tools; + } catch (error) { + console.error("Error fetching MCP tools:", error); + throw error; + } } // === CLI Mode === if (import.meta.main) { - const tools = await getMcpTools(); - await Bun.write("./tools.json", JSON.stringify(tools, null, 2)); -} + try { + const tools = await getMcpTools(); + + if (tools.length === 0) { + console.warn("⚠️ No tools generated. Check your OpenAPI spec."); + } + + await Bun.write("./tools.json", JSON.stringify(tools, null, 2)); + console.log(`✅ Written ${tools.length} tools to tools.json`); + + console.log("\n📋 Generated tools:"); + tools.forEach(tool => { + const reqCount = tool.inputSchema.required?.length || 0; + const propCount = Object.keys(tool.inputSchema.properties || {}).length; + console.log(` - ${tool.name}`); + console.log(` ${tool["x-props"].method} ${tool["x-props"].path}`); + console.log(` Props: ${propCount}, Required: ${reqCount}`); + }); + } catch (error) { + console.error("❌ Failed to generate tools:", error); + process.exit(1); + } +} \ No newline at end of file diff --git a/src/server/lib/mcp_tool_convert.txt b/src/server/lib/mcp_tool_convert.txt new file mode 100644 index 0000000..0ddec63 --- /dev/null +++ b/src/server/lib/mcp_tool_convert.txt @@ -0,0 +1,108 @@ +import _ from "lodash"; +import { v4 as uuidv4 } from "uuid"; + +interface McpTool { + name: string; + description: string; + inputSchema: any; + "x-props": { + method: string; + path: string; + operationId?: string; + tag?: string; + deprecated?: boolean; + summary?: string; + }; +} + +/** + * Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions (without run()). + * Hanya menyertakan endpoint yang memiliki tag berisi "mcp". + */ +export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] { + const tools: McpTool[] = []; + const paths = openApiJson.paths || {}; + + for (const [path, methods] of Object.entries(paths)) { + // ✅ skip semua path internal MCP + if (path.startsWith("/mcp")) continue; + + for (const [method, operation] of Object.entries(methods as any)) { + const tags: string[] = Array.isArray(operation.tags) ? operation.tags : []; + + // ✅ exclude semua yang tidak punya tag atau tag-nya tidak mengandung "mcp" + if (!tags.length || !tags.some(t => t.toLowerCase().includes("mcp"))) continue; + + const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool"; + const name = cleanToolName(rawName); + + const description = + operation.description || + operation.summary || + `Execute ${method.toUpperCase()} ${path}`; + + const schema = + operation.requestBody?.content?.["application/json"]?.schema || { + type: "object", + properties: {}, + additionalProperties: true, + }; + + const tool: McpTool = { + name, + description, + "x-props": { + method: method.toUpperCase(), + path, + operationId: operation.operationId, + tag: tags[0], + deprecated: operation.deprecated || false, + summary: operation.summary, + }, + inputSchema: { + ...schema, + additionalProperties: true, + $schema: "http://json-schema.org/draft-07/schema#", + }, + }; + + tools.push(tool); + } + } + + return tools; +} + +/** + * Bersihkan nama agar valid untuk digunakan sebagai tool name + * - hapus karakter spesial + * - ubah slash jadi underscore + * - hilangkan prefix umum (get_, post_, api_, dll) + * - rapikan underscore berganda + */ +function cleanToolName(name: string): string { + return name + .replace(/[{}]/g, "") + .replace(/[^a-zA-Z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .replace(/^(get|post|put|delete|patch|api)_/i, "") + .replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "") + .replace(/(^_|_$)/g, ""); +} + +/** + * Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP + */ +export async function getMcpTools() { + const data = await fetch(`${process.env.BUN_PUBLIC_BASE_URL}/docs/json`); + const openApiJson = await data.json(); + const tools = convertOpenApiToMcpTools(openApiJson); + return tools; +} + +// === CLI Mode === +if (import.meta.main) { + const tools = await getMcpTools(); + await Bun.write("./tools.json", JSON.stringify(tools, null, 2)); +} diff --git a/src/server/routes/test.ts b/src/server/routes/test.ts index d14e25c..3239439 100644 --- a/src/server/routes/test.ts +++ b/src/server/routes/test.ts @@ -25,7 +25,7 @@ const TestRoute = new Elysia({ }) .post("/simpan-rapat", ({ body }) => { - if (!body.gambar) { + if (!body.image_name) { return { success: false, message: "gambar harus diisi", @@ -34,14 +34,14 @@ const TestRoute = new Elysia({ return { success: true, message: "data info rapat berhasil diambil", - chunk: body.gambar.substring(22) + chunk: body.image_name.substring(22) } }, { body: t.Object({ judul: t.String(), tanggal: t.String(), deskripsi: t.String(), - gambar: t.Required(t.String()), + image_name: t.Required(t.String()), }), detail: { summary: "simpan data rapat",