diff --git a/src/server/routes/mcp_route.ts b/src/server/routes/mcp_route.ts index 842013b..a3c298d 100644 --- a/src/server/routes/mcp_route.ts +++ b/src/server/routes/mcp_route.ts @@ -1,376 +1,224 @@ import { Elysia } from "elysia"; -import { v4 as uuidv4 } from "uuid"; - -// const API_KEY = process.env.MCP_API_KEY ?? "super-secret-key"; -// const PORT = Number(process.env.PORT ?? 3000); - -// // ===================== -// // Helper Functions -// // ===================== -// function isAuthorized(headers: Headers) { -// const authHeader = headers.get("authorization"); -// if (authHeader?.startsWith("Bearer ")) { -// const token = authHeader.substring(7); -// return token === API_KEY; -// } -// return headers.get("x-api-key") === API_KEY; -// } - -// ===================== -// Tools Definition -// ===================== -type Tool = { - name: string; - description: string; - inputSchema: { - type: string; - properties: Record; - required?: string[]; - additionalProperties?: boolean; - $schema?: string; - }; - run: (input?: any) => Promise; -}; - -const tools: Tool[] = [ - { - name: "perbekal_darmasaba", - description: "Mengembalikan nama perbekal darmasaba", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: true, - $schema: "http://json-schema.org/draft-07/schema#", - }, - run: async () => ({ perbekal_darmasaba: "malik kurosaki" }), - }, - { - name: "uuid", - description: "Menghasilkan UUID v4 unik.", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: true, - $schema: "http://json-schema.org/draft-07/schema#", - }, - run: async () => ({ uuid: uuidv4() }), - }, - { - name: "echo", - description: "Mengembalikan data yang dikirim.", - inputSchema: { - type: "object", - properties: { - input: { - type: "string", - description: "Message to echo back", - }, - }, - required: ["input"], - additionalProperties: true, - $schema: "http://json-schema.org/draft-07/schema#", - }, - run: async (input) => ({ echo: input }), - }, - { - name: "Calculator", - description: "Useful for getting the result of a math expression. The input to this tool should be a valid mathematical expression that could be executed by a simple calculator.", - inputSchema: { - type: "object", - properties: { - input: { - type: "string", - }, - }, - required: ["input"], - additionalProperties: true, - $schema: "http://json-schema.org/draft-07/schema#", - }, - run: async (input) => { - try { - // Simple math evaluation (be careful in production!) - const result = Function(`"use strict"; return (${input.input})`)(); - return { result: String(result) }; - } catch (error: any) { - throw new Error(`Invalid expression: ${error.message}`); - } - }, - }, -]; +import tools from "./../../../tools.json"; // ===================== // MCP Protocol Types // ===================== type JSONRPCRequest = { - jsonrpc: "2.0"; - id: string | number; - method: string; - params?: any; + jsonrpc: "2.0"; + id: string | number; + method: string; + params?: any; }; type JSONRPCResponse = { - jsonrpc: "2.0"; - id: string | number; - result?: any; - error?: { - code: number; - message: string; - data?: any; + jsonrpc: "2.0"; + id: string | number; + result?: any; + error?: { + code: number; + message: string; + data?: any; + }; +}; + +// ===================== +// Tool Executor +// ===================== +export async function executeTool( + tool: any, + args: Record = {}, + baseUrl: string +) { + const x = tool["x-props"] || {}; + + const method = (x.method || "GET").toUpperCase(); + const path = x.path || `/${tool.name}`; + const url = `${baseUrl}${path}`; + + const opts: RequestInit = { + method, + headers: { "Content-Type": "application/json" }, + }; + + if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + opts.body = JSON.stringify(args || {}); + } + + const res = await fetch(url, opts); + const contentType = res.headers.get("content-type") || ""; + const data = contentType.includes("application/json") + ? await res.json() + : await res.text(); + + return { + success: res.ok, + status: res.status, + method, + path, + data, + }; +} + +// ===================== +// MCP Handler (Async) +// ===================== +async function handleMCPRequestAsync( + request: JSONRPCRequest +): Promise { + const { id, method, params } = request; + + switch (method) { + case "initialize": + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "elysia-mcp-server", version: "1.0.0" }, + }, + }; + + case "tools/list": + return { + jsonrpc: "2.0", + id, + result: { + tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({ + name, + description, + inputSchema, + "x-props": x, + })), + }, + }; + + case "tools/call": { + const toolName = params?.name; + const tool = tools.find((t) => t.name === toolName); + + if (!tool) { + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Tool '${toolName}' not found` }, + }; + } + + try { + const baseUrl = + process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000"; + const result = await executeTool(tool, params?.arguments || {}, baseUrl); + + return { + jsonrpc: "2.0", + id, + result: { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }, + }; + } catch (error: any) { + return { + jsonrpc: "2.0", + id, + error: { code: -32603, message: error.message }, + }; + } + } + + case "ping": + return { jsonrpc: "2.0", id, result: {} }; + + default: + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method '${method}' not found` }, + }; + } +} + +// ===================== +// Elysia MCP Server +// ===================== +export const MCPRoute = new Elysia({ + tags: ["MCP"] +}) + .post("/mcp", async ({ request, set }) => { + set.headers["Content-Type"] = "application/json"; + set.headers["Access-Control-Allow-Origin"] = "*"; + + try { + const body = await request.json(); + + if (!Array.isArray(body)) { + const res = await handleMCPRequestAsync(body); + return res; + } + + const results = await Promise.all( + body.map((req) => handleMCPRequestAsync(req)) + ); + return results; + } catch (error: any) { + set.status = 400; + return { + jsonrpc: "2.0", + id: null, + error: { + code: -32700, + message: "Parse error", + data: error.message, + }, + }; + } + }) + + // Tools list (debug) + .get("/mcp/tools", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + return { + tools: tools.map(({ name, description, inputSchema, ["x-props"]: x }) => ({ + name, + description, + inputSchema, + "x-props": x, + })), }; -}; + }) -type JSONRPCNotification = { - jsonrpc: "2.0"; - method: string; - params?: any; -}; + // MCP status + .get("/mcp/status", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + return { status: "active", timestamp: Date.now() }; + }) -// ===================== -// MCP Handler -// ===================== -function handleMCPRequest(request: JSONRPCRequest): JSONRPCResponse { - const { id, method, params } = request; + // Health check + .get("/health", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + return { status: "ok", timestamp: Date.now(), tools: tools.length }; + }) - switch (method) { - case "initialize": - return { - jsonrpc: "2.0", - id, - result: { - protocolVersion: "2024-11-05", - capabilities: { - tools: {}, - }, - serverInfo: { - name: "elysia-mcp-server", - version: "1.0.0", - }, - }, - }; - - case "tools/list": - return { - jsonrpc: "2.0", - id, - result: { - tools: tools.map(({ name, description, inputSchema }) => ({ - name, - description, - inputSchema, - })), - }, - }; - - case "tools/call": - const toolName = params?.name; - const tool = tools.find((t) => t.name === toolName); - - if (!tool) { - return { - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: `Tool '${toolName}' not found`, - }, - }; - } - - try { - // Note: This is synchronous for simplicity - // In real implementation, you'd need to handle async properly - let result: any; - tool.run(params?.arguments || {}).then((r) => (result = r)); - - return { - jsonrpc: "2.0", - id, - result: { - content: [ - { - type: "text", - text: JSON.stringify(result || { pending: true }), - }, - ], - }, - }; - } catch (error: any) { - return { - jsonrpc: "2.0", - id, - error: { - code: -32603, - message: error.message, - }, - }; - } - - case "ping": - return { - jsonrpc: "2.0", - id, - result: {}, - }; - - default: - return { - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: `Method '${method}' not found`, - }, - }; - } -} - -async function handleMCPRequestAsync(request: JSONRPCRequest): Promise { - const { id, method, params } = request; - - if (method === "tools/call") { - const toolName = params?.name; - const tool = tools.find((t) => t.name === toolName); - - if (!tool) { - return { - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: `Tool '${toolName}' not found`, - }, - }; - } - - try { - const result = await tool.run(params?.arguments || {}); - return { - jsonrpc: "2.0", - id, - result: { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }, - }; - } catch (error: any) { - return { - jsonrpc: "2.0", - id, - error: { - code: -32603, - message: error.message, - }, - }; - } - } - - // For other methods, use sync handler - return handleMCPRequest(request); -} - -// ===================== -// Server Initialization -// ===================== -export const MCPRoute = new Elysia() - // ===================== - // MCP HTTP Streamable Endpoint - // ===================== - .post("/mcp/:sessionId", async ({ params, request, set }) => { - set.headers["Content-Type"] = "application/json"; - set.headers["Access-Control-Allow-Origin"] = "*"; - - // Optional: Check authorization - // if (!isAuthorized(request.headers)) { - // set.status = 401; - // return { error: "Unauthorized" }; - // } - - try { - const body = await request.json(); - - // Handle single request - if (!Array.isArray(body)) { - const response = await handleMCPRequestAsync(body as JSONRPCRequest); - return response; - } - - // Handle batch requests - const responses = await Promise.all( - body.map((req) => handleMCPRequestAsync(req as JSONRPCRequest)) - ); - return responses; - } catch (error: any) { - set.status = 400; - return { - jsonrpc: "2.0", - id: null, - error: { - code: -32700, - message: "Parse error", - data: error.message, - }, - }; - } - }) - - // ===================== - // Simple tools list endpoint (for debugging) - // ===================== - .get("/mcp/:sessionId/tools", ({ set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - return { - data: tools.map(({ name, description, inputSchema }) => ({ - name, - value: name, - description, - inputSchema, - })), - }; - }) - - // ===================== - // Session Status - // ===================== - .get("/mcp/:sessionId/status", ({ params, set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - return { - sessionId: params.sessionId, - status: "active", - timestamp: Date.now(), - }; - }) - - // ===================== - // Health Check - // ===================== - .get("/health", ({ set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - return { - status: "ok", - timestamp: Date.now(), - tools: tools.length, - }; - }) - - // ===================== - // CORS preflight - // ===================== - .options("/mcp/:sessionId", ({ set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"; - set.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-API-Key"; - set.status = 204; - return ""; - }) - - .options("/mcp/:sessionId/tools", ({ set }) => { - set.headers["Access-Control-Allow-Origin"] = "*"; - set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS"; - set.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization,X-API-Key"; - set.status = 204; - return ""; - }); \ No newline at end of file + // CORS + .options("/mcp", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + set.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"; + set.headers["Access-Control-Allow-Headers"] = + "Content-Type,Authorization,X-API-Key"; + set.status = 204; + return ""; + }) + .options("/mcp/tools", ({ set }) => { + set.headers["Access-Control-Allow-Origin"] = "*"; + set.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS"; + set.headers["Access-Control-Allow-Headers"] = + "Content-Type,Authorization,X-API-Key"; + set.status = 204; + return ""; + }); diff --git a/tools.json b/tools.json new file mode 100644 index 0000000..4195020 --- /dev/null +++ b/tools.json @@ -0,0 +1,612 @@ +[ + { + "name": "apikey_create", + "description": "create api key by user", + "x-props": { + "method": "POST", + "path": "/api/apikey/create", + "operationId": "postApiApikeyCreate", + "tag": "apikey", + "deprecated": false, + "summary": "create" + }, + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "expiredAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "apikey_list", + "description": "get api key list by user", + "x-props": { + "method": "GET", + "path": "/api/apikey/list", + "operationId": "getApiApikeyList", + "tag": "apikey", + "deprecated": false, + "summary": "list" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "apikey_delete", + "description": "delete api key by id", + "x-props": { + "method": "DELETE", + "path": "/api/apikey/delete", + "operationId": "deleteApiApikeyDelete", + "tag": "apikey", + "deprecated": false, + "summary": "delete" + }, + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "darmasaba_repos", + "description": "get list of repositories", + "x-props": { + "method": "GET", + "path": "/api/darmasaba/repos", + "operationId": "getApiDarmasabaRepos", + "tag": "darmasaba", + "deprecated": false, + "summary": "repos" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "darmasaba_ls", + "description": "get list of dir in darmasaba", + "x-props": { + "method": "GET", + "path": "/api/darmasaba/ls", + "operationId": "getApiDarmasabaLs", + "tag": "darmasaba", + "deprecated": false, + "summary": "ls" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "darmasaba_ls_by_dir", + "description": "get list of files in darmasaba/", + "x-props": { + "method": "GET", + "path": "/api/darmasaba/ls/{dir}", + "operationId": "getApiDarmasabaLsByDir", + "tag": "darmasaba", + "deprecated": false, + "summary": "ls" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "darmasaba_file_by_dir_by_file_name", + "description": "get content of file in darmasaba//", + "x-props": { + "method": "GET", + "path": "/api/darmasaba/file/{dir}/{file_name}", + "operationId": "getApiDarmasabaFileByDirByFile_name", + "tag": "darmasaba", + "deprecated": false, + "summary": "file" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "darmasaba_list_pengetahuan_umum", + "description": "get list of files in darmasaba/pengetahuan-umum", + "x-props": { + "method": "GET", + "path": "/api/darmasaba/list-pengetahuan-umum", + "operationId": "getApiDarmasabaList-pengetahuan-umum", + "tag": "darmasaba", + "deprecated": false, + "summary": "list-pengetahuan-umum" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "darmasaba_pengetahuan_umum_by_file_name", + "description": "get content of file in darmasaba/pengetahuan-umum/", + "x-props": { + "method": "GET", + "path": "/api/darmasaba/pengetahuan-umum/{file_name}", + "operationId": "getApiDarmasabaPengetahuan-umumByFile_name", + "tag": "darmasaba", + "deprecated": false, + "summary": "pengetahuan-umum" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "darmasaba_buat_pengaduan", + "description": "tool untuk membuat pengaduan atau pelaporan warga kepada desa darmasaba", + "x-props": { + "method": "POST", + "path": "/api/darmasaba/buat-pengaduan", + "operationId": "postApiDarmasabaBuat-pengaduan", + "tag": "darmasaba", + "deprecated": false, + "summary": "buat-pengaduan atau pelaporan" + }, + "inputSchema": { + "type": "object", + "properties": { + "jenis_laporan": { + "minLength": 1, + "error": "jenis laporan harus diisi", + "type": "string" + }, + "name": { + "minLength": 1, + "error": "name harus diisi", + "type": "string" + }, + "phone": { + "minLength": 1, + "error": "phone harus diisi", + "type": "string" + }, + "detail": { + "minLength": 1, + "error": "detail harus diisi", + "type": "string" + } + }, + "required": [ + "jenis_laporan", + "name", + "phone", + "detail" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "darmasaba_status_pengaduan", + "description": "melikat status pengaduan dari user", + "x-props": { + "method": "POST", + "path": "/api/darmasaba/status-pengaduan", + "operationId": "postApiDarmasabaStatus-pengaduan", + "tag": "darmasaba", + "deprecated": false, + "summary": "lihat status pengaduan" + }, + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "phone": { + "type": "string" + } + }, + "required": [ + "name", + "phone" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "credential_create", + "description": "create credential", + "x-props": { + "method": "POST", + "path": "/api/credential/create", + "operationId": "postApiCredentialCreate", + "tag": "credential", + "deprecated": false, + "summary": "create" + }, + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "credential_list", + "description": "get credential list", + "x-props": { + "method": "GET", + "path": "/api/credential/list", + "operationId": "getApiCredentialList", + "tag": "credential", + "deprecated": false, + "summary": "list" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "credential_rm", + "description": "delete credential by id", + "x-props": { + "method": "DELETE", + "path": "/api/credential/rm", + "operationId": "deleteApiCredentialRm", + "tag": "credential", + "deprecated": false, + "summary": "rm" + }, + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "user_find", + "description": "find user", + "x-props": { + "method": "GET", + "path": "/api/user/find", + "operationId": "getApiUserFind", + "tag": "user", + "deprecated": false, + "summary": "find" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "user_upsert", + "description": "upsert user", + "x-props": { + "method": "POST", + "path": "/api/user/upsert", + "operationId": "postApiUserUpsert", + "tag": "user", + "deprecated": false, + "summary": "upsert" + }, + "inputSchema": { + "type": "object", + "properties": { + "name": { + "minLength": 1, + "error": "name is required", + "type": "string" + }, + "phone": { + "minLength": 1, + "error": "phone is required", + "type": "string" + } + }, + "required": [ + "name", + "phone" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "layanan_list", + "description": "Returns the list of all available public services.", + "x-props": { + "method": "GET", + "path": "/api/layanan/list", + "operationId": "getApiLayananList", + "tag": "layanan", + "deprecated": false, + "summary": "List Layanan" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "layanan_create_ktp", + "description": "Create a new service request for KTP or KK.", + "x-props": { + "method": "POST", + "path": "/api/layanan/create-ktp", + "operationId": "postApiLayananCreate-ktp", + "tag": "layanan", + "deprecated": false, + "summary": "Create Layanan KTP/KK" + }, + "inputSchema": { + "type": "object", + "properties": { + "jenis": { + "anyOf": [ + { + "const": "ktp", + "type": "string" + }, + { + "const": "kk", + "type": "string" + } + ] + }, + "nama": { + "minLength": 3, + "description": "Nama pemohon layanan", + "type": "string" + }, + "deskripsi": { + "minLength": 5, + "description": "Deskripsi singkat permohonan layanan", + "type": "string" + } + }, + "required": [ + "jenis", + "nama", + "deskripsi" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "layanan_status_ktp", + "description": "Retrieve the current status of a KTP/KK request by unique ID.", + "x-props": { + "method": "POST", + "path": "/api/layanan/status-ktp", + "operationId": "postApiLayananStatus-ktp", + "tag": "layanan", + "deprecated": false, + "summary": "Cek Status KTP" + }, + "inputSchema": { + "type": "object", + "properties": { + "uniqid": { + "description": "Unique ID layanan", + "type": "string" + } + }, + "required": [ + "uniqid" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "aduan_create", + "description": "create aduan", + "x-props": { + "method": "POST", + "path": "/api/aduan/create", + "operationId": "postApiAduanCreate", + "tag": "aduan", + "deprecated": false, + "summary": "create" + }, + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "title", + "description" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "aduan_aduan_sampah", + "description": "tool untuk membuat aduan sampah liar", + "x-props": { + "method": "POST", + "path": "/api/aduan/aduan-sampah", + "operationId": "postApiAduanAduan-sampah", + "tag": "aduan", + "deprecated": false, + "summary": "aduan sampah" + }, + "inputSchema": { + "type": "object", + "properties": { + "judul": { + "type": "string" + }, + "deskripsi": { + "type": "string" + } + }, + "required": [ + "judul", + "deskripsi" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "aduan_list_aduan_sampah", + "description": "tool untuk melihat list aduan sampah liar", + "x-props": { + "method": "GET", + "path": "/api/aduan/list-aduan-sampah", + "operationId": "getApiAduanList-aduan-sampah", + "tag": "aduan", + "deprecated": false, + "summary": "list aduan sampah" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "auth_login", + "description": "Login with phone; auto-register if not found", + "x-props": { + "method": "POST", + "path": "/auth/login", + "operationId": "postAuthLogin", + "tag": "auth", + "deprecated": false, + "summary": "login" + }, + "inputSchema": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "auth_logout", + "description": "Logout (clear token cookie)", + "x-props": { + "method": "DELETE", + "path": "/auth/logout", + "operationId": "deleteAuthLogout", + "tag": "auth", + "deprecated": false, + "summary": "logout" + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "health", + "description": "Execute GET /health", + "x-props": { + "method": "GET", + "path": "/health", + "operationId": "getHealth", + "deprecated": false + }, + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } +] \ No newline at end of file diff --git a/xxx/tool_convert.ts b/xxx/tool_convert.ts new file mode 100644 index 0000000..e02ea3e --- /dev/null +++ b/xxx/tool_convert.ts @@ -0,0 +1,100 @@ +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()). + * Each tool corresponds to an endpoint, with metadata stored under `x-props`. + */ +export function convertOpenApiToMcpTools(openApiJson: any, baseUrl: string = ""): 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 rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool"; + const name = cleanToolName(rawName); + + const summary = operation.summary || `Execute ${method.toUpperCase()} ${path}`; + 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: Array.isArray(operation.tags) ? operation.tags[0] : undefined, + deprecated: operation.deprecated || false, + summary: operation.summary, // ✅ tambahkan summary ke metadata + }, + 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, ""); +} + +// === Contoh Pemakaian === +// import openApiJson from "./openapi.json"; +// const tools = convertOpenApiToMcpTools(openApiJson, "https://api.wibudev.com"); +// console.log(JSON.stringify(tools, null, 2)); + +if (import.meta.main) { + const data = await fetch("http://localhost:3000/docs/json"); + const openApiJson = await data.json(); + const tools = convertOpenApiToMcpTools(openApiJson, "http://localhost:3000"); + Bun.write("./tools.json", JSON.stringify(tools, null, 2)); +}