UNPKG

@nicepay/start-api-mcp

Version:

NICEPAY 결제 연동을 위한 MCP 툴 (https://start.nicepay.co.kr)

259 lines (258 loc) 10.7 kB
import SwaggerParser from "@apidevtools/swagger-parser"; import { z } from "zod"; import { fileURLToPath } from "url"; import fs from "fs"; import path from "path"; import { generateClientCodeFromTemplate } from "./code_generator.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, "../../"); const DEFAULT_OPENAPI_DIR = path.join(__dirname, "../openapi"); function formatGroups(groups) { return groups.map((g) => ({ name: g.name, description: g.description, methods: Object.entries(g.methods).map(([n, m]) => ({ name: n, description: m.description, isClient: m.isClient, parameters: { ...(m.isClient ? {} : { basicAuthToken: "Base64 encoded clientId:secretKey" }), ...m.paramInfo, }, ...(m.operation["x-js-sdk"] && { jsSdk: m.operation["x-js-sdk"] }), ...(m.operation["x-mcp-template"] && { template: m.operation["x-mcp-template"] }), })), })); } function writeGroupsFile(groups) { const outPath = path.join(projectRoot, "dist", "tool_groups.json"); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, JSON.stringify(formatGroups(groups), null, 2)); } function resolveRef(ref, openapi) { if (!ref.startsWith('#/')) return undefined; const parts = ref.replace(/^#\//, '').split('/'); let current = openapi; for (const p of parts) { current = current?.[p]; } return current; } function openApiSchemaToZod(schema, openapi) { if (!schema) return z.any(); if (schema.$ref && openapi) { const resolved = resolveRef(schema.$ref, openapi); return openApiSchemaToZod(resolved, openapi); } if (schema.type === "object" || schema.properties) { const shape = {}; const required = new Set(schema.required || []); for (const key in schema.properties || {}) { let child = openApiSchemaToZod(schema.properties[key], openapi); if (!required.has(key)) child = child.optional(); shape[key] = child; } return z.object(shape).describe(schema.description || ""); } if (schema.type === "array" && schema.items) { return z.array(openApiSchemaToZod(schema.items, openapi)).describe(schema.description || ""); } if (schema.enum && Array.isArray(schema.enum)) { const values = schema.enum; const allStrings = values.every((v) => typeof v === "string"); if (allStrings && values.length > 0) { return z.enum(values).describe(schema.description || ""); } if (values.length > 0) { const literals = values.map((v) => z.literal(v)); return z.union(literals).describe(schema.description || ""); } } switch (schema.type) { case "string": return z.string().describe(schema.description || ""); case "integer": case "number": return z.number().describe(schema.description || ""); case "boolean": return z.boolean().describe(schema.description || ""); default: return z.any().describe(schema?.description || ""); } } function flattenSchemaProperties(schema, openapi, shape, info, bodyKeys) { if (!schema) return; if (schema.$ref) { const resolved = resolveRef(schema.$ref, openapi); return flattenSchemaProperties(resolved, openapi, shape, info, bodyKeys); } if (schema.type === "object" || schema.properties) { const required = new Set(schema.required || []); for (const key in schema.properties || {}) { const prop = schema.properties[key]; let zodType = openApiSchemaToZod(prop, openapi); if (!required.has(key)) zodType = zodType.optional(); shape[key] = zodType; info[key] = prop.description || ""; bodyKeys.add(key); } return; } shape["body"] = openApiSchemaToZod(schema, openapi); info["body"] = schema.description || ""; bodyKeys.add("body"); } function shortDescription(op) { const text = op.summary || op.description || ""; const first = text.split(/\n|\./)[0].trim(); return first; } async function buildToolGroups(openapi) { const groups = {}; for (const pathKey in openapi.paths) { const item = openapi.paths[pathKey]; for (const method of ["get", "post", "put", "delete", "patch"]) { const op = item[method]; if (!op) continue; const tag = op.tags?.[0] || "default"; if (!groups[tag]) { groups[tag] = { name: tag, description: "", methods: {} }; } const methodName = op.operationId || `${method}_${pathKey.replace(/[\/{\}]/g, "_")}`; const schema = {}; const paramInfo = {}; const bodyKeys = new Set(); if (Array.isArray(op.parameters)) { for (const p of op.parameters) { let zodType = openApiSchemaToZod(p.schema, openapi); const desc = p.description || p.schema?.description || ""; if (desc) zodType = zodType.describe(desc); if (!p.required) zodType = zodType.optional(); schema[p.name] = zodType; paramInfo[p.name] = desc; } } const bodySchema = op.requestBody?.content?.["application/json"] ?.schema; if (bodySchema) { flattenSchemaProperties(bodySchema, openapi, schema, paramInfo, bodyKeys); } const respSchema = op.responses?.["200"]?.content?.["application/json"]?.schema; const output = respSchema ? openApiSchemaToZod(respSchema, openapi) : undefined; groups[tag].methods[methodName] = { description: shortDescription(op), schema, paramInfo, bodyKeys, output, operation: op, path: pathKey, method, isClient: op["x-mcp-client-tool"] === true, }; } } const result = Object.values(groups); for (const g of result) { const entries = Object.entries(g.methods).map(([n, m]) => `${n}: ${m.description}`); const list = entries.slice(0, 5).join(" / "); g.description = entries.length > 5 ? `${list} 등` : list; } return result; } export async function generateToolGroupsFile() { const openapiPath = path.join(DEFAULT_OPENAPI_DIR, "openapi.yaml"); if (!fs.existsSync(openapiPath)) return; const openapi = (await SwaggerParser.validate(openapiPath, { dereference: { circular: "ignore" }, })); const groups = await buildToolGroups(openapi); writeGroupsFile(groups); } export async function autoRegisterTools(server) { const openapiPath = path.join(DEFAULT_OPENAPI_DIR, "openapi.yaml"); if (!fs.existsSync(openapiPath)) return; try { const openapi = (await SwaggerParser.validate(openapiPath, { dereference: { circular: "ignore" }, })); const groups = await buildToolGroups(openapi); writeGroupsFile(groups); for (const group of groups) { for (const [name, m] of Object.entries(group.methods)) { if (m.isClient) { server.tool(name, m.description, m.schema, (args) => { const code = generateClientCodeFromTemplate(m.operation, args); return { content: [{ type: "text", text: code }] }; }); } else { const toolSchema = { basicAuthToken: z .string() .describe("Base64 encoded clientId:secretKey"), ...m.schema, }; server.tool(name, m.description, toolSchema, async (args) => { const { basicAuthToken, ...input } = args; const baseUrl = m.operation.servers?.[0]?.url || openapi.servers?.[0]?.url || ""; let url = baseUrl + m.path; const query = new URLSearchParams(); if (Array.isArray(m.operation.parameters)) { for (const p of m.operation.parameters) { const val = input[p.name]; if (val === undefined) continue; if (p.in === "path") { url = url.replace(`{${p.name}}`, val); } else if (p.in === "query") { query.append(p.name, String(val)); } } } const finalUrl = query.toString() ? `${url}?${query.toString()}` : url; const body = {}; for (const key of m.bodyKeys) { if (input[key] !== undefined) { body[key] = input[key]; } } const apiCallInfo = { description: `This JSON object contains the necessary details to make the '${name}' API call.`, requestDetails: { method: m.method.toUpperCase(), url: finalUrl, headers: { "Content-Type": "application/json", Authorization: `Basic ${basicAuthToken}`, }, body, }, }; return { content: [{ type: "text", text: JSON.stringify(apiCallInfo, null, 2) }], }; }); } } } } catch { // suppress errors for non-stdio environments } } export { buildToolGroups };