magicpod-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/build/index.js +27 -0
- package/build/openapi-mcp-server/auth/index.js +2 -0
- package/build/openapi-mcp-server/auth/template.js +18 -0
- package/build/openapi-mcp-server/auth/types.js +1 -0
- package/build/openapi-mcp-server/client/http-client.js +177 -0
- package/build/openapi-mcp-server/index.js +2 -0
- package/build/openapi-mcp-server/mcp/proxy.js +217 -0
- package/build/openapi-mcp-server/openapi/file-upload.js +34 -0
- package/build/openapi-mcp-server/openapi/parser.js +470 -0
- package/build/tools/magicpod-web-api.js +43 -0
- package/build/tools/read-magicpod-article.js +43 -0
- package/build/tools/search-magicpod-articles.js +51 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Magic-Pod
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# magicpod-mcp-server
|
|
2
|
+
|
|
3
|
+
An MCP (Model Context Protocol) server that integrates your AI agents with MagicPod
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
[Cursor](https://cursor.com), [Claude](https://claude.ai/), and many other AI-powered coding tools support MCP servers. You can refer to their official documents on how to configure MCP servers. For example, if you use Claude Desktop, what you have to do to integrate with MagicPod is only to add the following lines in your `claude_desktop_config.json`.
|
|
8
|
+
|
|
9
|
+
### MacOS / Linux
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"magicpod-mcp-server": {
|
|
15
|
+
"command": "npx",
|
|
16
|
+
"args": ["-y", "magicpod-mcp-server", "--api-token=YOUR-API-TOKEN"]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Windows
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"mcpServers": {
|
|
27
|
+
"magicpod-mcp-server": {
|
|
28
|
+
"command": "cmd",
|
|
29
|
+
"args": ["/c", "npx", "-y", "magicpod-mcp-server", "--api-token=YOUR-API-TOKEN"]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Make sure that you replace `YOUR-API-TOKEN` with your actual MagicPod API token. You can retrieve it on the [integrations](https://app.magicpod.com/accounts/api-token/) screen.
|
|
36
|
+
|
|
37
|
+
<img width="1015" alt="retrieve API token" src="https://github.com/user-attachments/assets/77931857-284d-4d7f-968b-c6a000f518c1" />
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
Build
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
npm run build
|
|
46
|
+
```
|
package/build/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { searchMagicpodArticles } from "./tools/search-magicpod-articles.js";
|
|
5
|
+
import { readMagicpodArticle } from "./tools/read-magicpod-article.js";
|
|
6
|
+
import { initMagicPodApiProxy } from "./tools/magicpod-web-api.js";
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program.option("--api-token <key>", "MagicPod API token to use");
|
|
9
|
+
program.parse(process.argv);
|
|
10
|
+
const options = program.opts();
|
|
11
|
+
if (!options.apiToken) {
|
|
12
|
+
console.error("--api-token must be provided");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
async function main() {
|
|
16
|
+
const baseUrl = process.env.BASE_URL || "https://app.magicpod.com";
|
|
17
|
+
const proxy = await initMagicPodApiProxy(baseUrl, options.apiToken, [
|
|
18
|
+
searchMagicpodArticles(),
|
|
19
|
+
readMagicpodArticle(),
|
|
20
|
+
]);
|
|
21
|
+
await proxy.connect(new StdioServerTransport());
|
|
22
|
+
console.error("MagicPod MCP Server running on stdio");
|
|
23
|
+
}
|
|
24
|
+
main().catch((error) => {
|
|
25
|
+
console.error("Fatal error in main():", error);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Mustache from "mustache";
|
|
2
|
+
export function renderAuthTemplate(template, context) {
|
|
3
|
+
// Disable HTML escaping for URLs
|
|
4
|
+
Mustache.escape = (text) => text;
|
|
5
|
+
// Render URL with template variables
|
|
6
|
+
const renderedUrl = Mustache.render(template.url, context);
|
|
7
|
+
// Create a new template object with rendered values
|
|
8
|
+
const renderedTemplate = {
|
|
9
|
+
...template,
|
|
10
|
+
url: renderedUrl,
|
|
11
|
+
headers: { ...template.headers }, // Create a new headers object to avoid modifying the original
|
|
12
|
+
};
|
|
13
|
+
// Render body if it exists
|
|
14
|
+
if (template.body) {
|
|
15
|
+
renderedTemplate.body = Mustache.render(template.body, context);
|
|
16
|
+
}
|
|
17
|
+
return renderedTemplate;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import OpenAPIClientAxios from "openapi-client-axios";
|
|
2
|
+
import FormData from "form-data";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { isFileUploadParameter } from "../openapi/file-upload.js";
|
|
5
|
+
export class HttpClientError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
data;
|
|
8
|
+
headers;
|
|
9
|
+
constructor(message, status, data, headers) {
|
|
10
|
+
super(`${status} ${message}`);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.data = data;
|
|
13
|
+
this.headers = headers;
|
|
14
|
+
this.name = "HttpClientError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class HttpClient {
|
|
18
|
+
api;
|
|
19
|
+
// @ts-ignore
|
|
20
|
+
client;
|
|
21
|
+
constructor(config, openApiSpec) {
|
|
22
|
+
this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({
|
|
23
|
+
definition: openApiSpec,
|
|
24
|
+
axiosConfigDefaults: {
|
|
25
|
+
baseURL: config.baseUrl,
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
"User-Agent": "magicpod-mcp-server",
|
|
29
|
+
...config.headers,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
this.api = this.client.init();
|
|
34
|
+
}
|
|
35
|
+
async prepareFileUpload(operation, params) {
|
|
36
|
+
console.error("prepareFileUpload", { operation, params });
|
|
37
|
+
const fileParams = isFileUploadParameter(operation);
|
|
38
|
+
if (fileParams.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
const formData = new FormData();
|
|
41
|
+
// Handle file uploads
|
|
42
|
+
for (const param of fileParams) {
|
|
43
|
+
console.error(`extracting ${param}`, { params });
|
|
44
|
+
const filePath = params[param];
|
|
45
|
+
if (!filePath) {
|
|
46
|
+
throw new Error(`File path must be provided for parameter: ${param}`);
|
|
47
|
+
}
|
|
48
|
+
switch (typeof filePath) {
|
|
49
|
+
case "string":
|
|
50
|
+
addFile(param, filePath);
|
|
51
|
+
break;
|
|
52
|
+
case "object":
|
|
53
|
+
if (Array.isArray(filePath)) {
|
|
54
|
+
let fileCount = 0;
|
|
55
|
+
for (const file of filePath) {
|
|
56
|
+
addFile(param, file);
|
|
57
|
+
fileCount++;
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
//deliberate fallthrough
|
|
62
|
+
default:
|
|
63
|
+
throw new Error(`Unsupported file type: ${typeof filePath}`);
|
|
64
|
+
}
|
|
65
|
+
function addFile(name, filePath) {
|
|
66
|
+
try {
|
|
67
|
+
const fileStream = fs.createReadStream(filePath);
|
|
68
|
+
formData.append(name, fileStream);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw new Error(`Failed to read file at ${filePath}: ${error}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Add non-file parameters to form data
|
|
76
|
+
for (const [key, value] of Object.entries(params)) {
|
|
77
|
+
if (!fileParams.includes(key)) {
|
|
78
|
+
formData.append(key, value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return formData;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Execute an OpenAPI operation
|
|
85
|
+
*/
|
|
86
|
+
async executeOperation(operation, params = {}) {
|
|
87
|
+
const api = await this.api;
|
|
88
|
+
const operationId = operation.operationId;
|
|
89
|
+
if (!operationId) {
|
|
90
|
+
throw new Error("Operation ID is required");
|
|
91
|
+
}
|
|
92
|
+
// Handle file uploads if present
|
|
93
|
+
const formData = await this.prepareFileUpload(operation, params);
|
|
94
|
+
// Separate parameters based on their location
|
|
95
|
+
const urlParameters = {};
|
|
96
|
+
let bodyParams = formData || { ...params };
|
|
97
|
+
// Extract path and query parameters based on operation definition
|
|
98
|
+
if (operation.parameters) {
|
|
99
|
+
for (const param of operation.parameters) {
|
|
100
|
+
if ("name" in param && param.name && param.in) {
|
|
101
|
+
if (param.in === "path" || param.in === "query") {
|
|
102
|
+
if (params[param.name] !== undefined) {
|
|
103
|
+
urlParameters[param.name] = params[param.name];
|
|
104
|
+
if (!formData) {
|
|
105
|
+
delete bodyParams[param.name];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Add all parameters as url parameters if there is no requestBody defined
|
|
113
|
+
if (!operation.requestBody && !formData) {
|
|
114
|
+
for (const key in bodyParams) {
|
|
115
|
+
if (bodyParams[key] !== undefined) {
|
|
116
|
+
urlParameters[key] = bodyParams[key];
|
|
117
|
+
delete bodyParams[key];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const operationFn = api[operationId];
|
|
122
|
+
if (!operationFn) {
|
|
123
|
+
throw new Error(`Operation ${operationId} not found`);
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
// If we have form data, we need to set the correct headers
|
|
127
|
+
const hasBody = Object.keys(bodyParams).length > 0;
|
|
128
|
+
const headers = formData
|
|
129
|
+
? formData.getHeaders()
|
|
130
|
+
: {
|
|
131
|
+
...(hasBody
|
|
132
|
+
? { "Content-Type": "application/json" }
|
|
133
|
+
: { "Content-Type": null }),
|
|
134
|
+
};
|
|
135
|
+
const requestConfig = {
|
|
136
|
+
headers: {
|
|
137
|
+
...headers,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
// first argument is url parameters, second is body parameters
|
|
141
|
+
console.error("calling operation", {
|
|
142
|
+
operationId,
|
|
143
|
+
urlParameters,
|
|
144
|
+
bodyParams,
|
|
145
|
+
requestConfig,
|
|
146
|
+
});
|
|
147
|
+
// `body` attribute is somehow included in an actual request
|
|
148
|
+
if ("body" in bodyParams) {
|
|
149
|
+
bodyParams = bodyParams.body;
|
|
150
|
+
}
|
|
151
|
+
const response = await operationFn(urlParameters, hasBody ? bodyParams : undefined, requestConfig);
|
|
152
|
+
// Convert axios headers to Headers object
|
|
153
|
+
const responseHeaders = new Headers();
|
|
154
|
+
Object.entries(response.headers).forEach(([key, value]) => {
|
|
155
|
+
if (value)
|
|
156
|
+
responseHeaders.append(key, value.toString());
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
data: response.data,
|
|
160
|
+
status: response.status,
|
|
161
|
+
headers: responseHeaders,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error.response) {
|
|
166
|
+
console.error("Error in http client", error);
|
|
167
|
+
const headers = new Headers();
|
|
168
|
+
Object.entries(error.response.headers).forEach(([key, value]) => {
|
|
169
|
+
if (value)
|
|
170
|
+
headers.append(key, value.toString());
|
|
171
|
+
});
|
|
172
|
+
throw new HttpClientError(error.response.statusText || "Request failed", error.response.status, error.response.data, headers);
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { OpenAPIToMCPConverter } from "../openapi/parser.js";
|
|
4
|
+
import { HttpClient, HttpClientError } from "../client/http-client.js";
|
|
5
|
+
import { zodToJsonSchema } from "openai/_vendor/zod-to-json-schema/index";
|
|
6
|
+
// import this class, extend and return server
|
|
7
|
+
export class MCPProxy {
|
|
8
|
+
otherTools;
|
|
9
|
+
server;
|
|
10
|
+
httpClient;
|
|
11
|
+
tools;
|
|
12
|
+
openApiLookup;
|
|
13
|
+
constructor(name, openApiSpec, apiToken, otherTools) {
|
|
14
|
+
this.otherTools = otherTools;
|
|
15
|
+
this.server = new Server({ name, version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
16
|
+
const baseUrl = openApiSpec.servers?.[0].url;
|
|
17
|
+
if (!baseUrl) {
|
|
18
|
+
throw new Error("No base URL found in OpenAPI spec");
|
|
19
|
+
}
|
|
20
|
+
this.httpClient = new HttpClient({
|
|
21
|
+
baseUrl,
|
|
22
|
+
headers: {
|
|
23
|
+
...this.parseHeadersFromEnv(),
|
|
24
|
+
Authorization: `Token ${apiToken}`,
|
|
25
|
+
},
|
|
26
|
+
}, openApiSpec);
|
|
27
|
+
// Convert OpenAPI spec to MCP tools
|
|
28
|
+
const converter = new OpenAPIToMCPConverter(openApiSpec);
|
|
29
|
+
const { tools, openApiLookup } = converter.convertToMCPTools();
|
|
30
|
+
this.tools = tools;
|
|
31
|
+
this.openApiLookup = openApiLookup;
|
|
32
|
+
this.setupHandlers();
|
|
33
|
+
}
|
|
34
|
+
removeDescriptions(obj) {
|
|
35
|
+
if (Array.isArray(obj)) {
|
|
36
|
+
obj.forEach(this.removeDescriptions);
|
|
37
|
+
}
|
|
38
|
+
else if (obj && typeof obj === "object") {
|
|
39
|
+
for (const key in obj) {
|
|
40
|
+
if (key === "description") {
|
|
41
|
+
// @ts-ignore
|
|
42
|
+
delete obj[key];
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// @ts-ignore
|
|
46
|
+
this.removeDescriptions(obj[key]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
collectRefs(obj) {
|
|
52
|
+
const refs = [];
|
|
53
|
+
if (Array.isArray(obj)) {
|
|
54
|
+
for (const childRefs of obj.map(this.collectRefs)) {
|
|
55
|
+
for (const childRef of childRefs) {
|
|
56
|
+
refs.push(childRef);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (obj && typeof obj === "object") {
|
|
61
|
+
for (const key in obj) {
|
|
62
|
+
if (key === "$ref") {
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
const ref = obj[key];
|
|
65
|
+
refs.push(ref.replaceAll("#/$defs/", ""));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// @ts-ignore
|
|
69
|
+
this.collectRefs(obj[key]).forEach(refs.push);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return refs;
|
|
74
|
+
}
|
|
75
|
+
setupHandlers() {
|
|
76
|
+
// Handle tool listing
|
|
77
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
78
|
+
const tools = [];
|
|
79
|
+
// Add methods as separate tools to match the MCP format
|
|
80
|
+
Object.entries(this.tools).forEach(([toolName, def]) => {
|
|
81
|
+
def.methods.forEach((method) => {
|
|
82
|
+
const toolNameWithMethod = `${toolName}-${method.name}`;
|
|
83
|
+
const truncatedToolName = this.truncateToolName(toolNameWithMethod);
|
|
84
|
+
// to reduce the tool list response size
|
|
85
|
+
// TODO description is actually required
|
|
86
|
+
const inputSchema = JSON.parse(JSON.stringify(method.inputSchema));
|
|
87
|
+
this.removeDescriptions(inputSchema);
|
|
88
|
+
// 95% of the response size is consumed by $defs
|
|
89
|
+
const body = method.inputSchema.properties?.body;
|
|
90
|
+
if (body == null || typeof body === "boolean") {
|
|
91
|
+
delete inputSchema["$defs"];
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const refs = this.collectRefs(body);
|
|
95
|
+
if (refs.length === 0) {
|
|
96
|
+
delete inputSchema["$defs"];
|
|
97
|
+
}
|
|
98
|
+
else if (inputSchema["$defs"]) {
|
|
99
|
+
for (const def of Object.keys(inputSchema["$defs"])) {
|
|
100
|
+
if (!refs.includes(def)) {
|
|
101
|
+
delete inputSchema["$defs"][def];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
tools.push({
|
|
107
|
+
name: truncatedToolName,
|
|
108
|
+
description: method.description,
|
|
109
|
+
inputSchema: inputSchema,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
for (const tool of this.otherTools) {
|
|
114
|
+
tools.push({
|
|
115
|
+
name: tool.name,
|
|
116
|
+
description: tool.description,
|
|
117
|
+
inputSchema: zodToJsonSchema(tool.inputSchema, "input").definitions
|
|
118
|
+
?.input,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { tools };
|
|
122
|
+
});
|
|
123
|
+
// Handle tool calling
|
|
124
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
125
|
+
console.error("calling tool", request.params);
|
|
126
|
+
const { name, arguments: params } = request.params;
|
|
127
|
+
const tool = this.otherTools.find((t) => t.name === name);
|
|
128
|
+
if (tool) {
|
|
129
|
+
return tool.handleRequest(params);
|
|
130
|
+
}
|
|
131
|
+
// Find the operation in OpenAPI spec
|
|
132
|
+
const operation = this.findOperation(name);
|
|
133
|
+
console.error("operations", this.openApiLookup);
|
|
134
|
+
if (!operation) {
|
|
135
|
+
throw new Error(`Method ${name} not found`);
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
// Execute the operation
|
|
139
|
+
const response = await this.httpClient.executeOperation(operation, params);
|
|
140
|
+
// Convert response to MCP format
|
|
141
|
+
return {
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: "text", // currently this is the only type that seems to be used by mcp server
|
|
145
|
+
text: JSON.stringify(response.data), // TODO: pass through the http status code text?
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
console.error("Error in tool call", error);
|
|
152
|
+
if (error instanceof HttpClientError) {
|
|
153
|
+
console.error("HttpClientError encountered, returning structured error", error);
|
|
154
|
+
const data = error.data?.response?.data ?? error.data ?? {};
|
|
155
|
+
return {
|
|
156
|
+
content: [
|
|
157
|
+
{
|
|
158
|
+
type: "text",
|
|
159
|
+
text: JSON.stringify({
|
|
160
|
+
status: "error", // TODO: get this from http status code?
|
|
161
|
+
...(typeof data === "object" ? data : { data: data }),
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
findOperation(operationId) {
|
|
172
|
+
return this.openApiLookup[operationId] ?? null;
|
|
173
|
+
}
|
|
174
|
+
parseHeadersFromEnv() {
|
|
175
|
+
const headersJson = process.env.OPENAPI_MCP_HEADERS;
|
|
176
|
+
if (!headersJson) {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const headers = JSON.parse(headersJson);
|
|
181
|
+
if (typeof headers !== "object" || headers === null) {
|
|
182
|
+
console.warn("OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:", typeof headers);
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
return headers;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
console.warn("Failed to parse OPENAPI_MCP_HEADERS environment variable:", error);
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
getContentType(headers) {
|
|
193
|
+
const contentType = headers.get("content-type");
|
|
194
|
+
if (!contentType)
|
|
195
|
+
return "binary";
|
|
196
|
+
if (contentType.includes("text") || contentType.includes("json")) {
|
|
197
|
+
return "text";
|
|
198
|
+
}
|
|
199
|
+
else if (contentType.includes("image")) {
|
|
200
|
+
return "image";
|
|
201
|
+
}
|
|
202
|
+
return "binary";
|
|
203
|
+
}
|
|
204
|
+
truncateToolName(name) {
|
|
205
|
+
if (name.length <= 64) {
|
|
206
|
+
return name;
|
|
207
|
+
}
|
|
208
|
+
return name.slice(0, 64);
|
|
209
|
+
}
|
|
210
|
+
async connect(transport) {
|
|
211
|
+
// The SDK will handle stdio communication
|
|
212
|
+
await this.server.connect(transport);
|
|
213
|
+
}
|
|
214
|
+
getServer() {
|
|
215
|
+
return this.server;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identifies file upload parameters in an OpenAPI operation
|
|
3
|
+
* @param operation The OpenAPI operation object to check
|
|
4
|
+
* @returns Array of parameter names that are file uploads
|
|
5
|
+
*/
|
|
6
|
+
export function isFileUploadParameter(operation) {
|
|
7
|
+
const fileParams = [];
|
|
8
|
+
if (!operation.requestBody)
|
|
9
|
+
return fileParams;
|
|
10
|
+
const requestBody = operation.requestBody;
|
|
11
|
+
const content = requestBody.content || {};
|
|
12
|
+
// Check multipart/form-data content type for file uploads
|
|
13
|
+
const multipartContent = content["multipart/form-data"];
|
|
14
|
+
if (!multipartContent?.schema)
|
|
15
|
+
return fileParams;
|
|
16
|
+
const schema = multipartContent.schema;
|
|
17
|
+
if (schema.type !== "object" || !schema.properties)
|
|
18
|
+
return fileParams;
|
|
19
|
+
// Look for properties with type: string, format: binary which indicates file uploads
|
|
20
|
+
Object.entries(schema.properties).forEach(([propName, prop]) => {
|
|
21
|
+
const schemaProp = prop;
|
|
22
|
+
if (schemaProp.type === "string" && schemaProp.format === "binary") {
|
|
23
|
+
fileParams.push(propName);
|
|
24
|
+
}
|
|
25
|
+
// Check for array of files
|
|
26
|
+
if (schemaProp.type === "array" && schemaProp.items) {
|
|
27
|
+
const itemSchema = schemaProp.items;
|
|
28
|
+
if (itemSchema.type === "string" && itemSchema.format === "binary") {
|
|
29
|
+
fileParams.push(propName);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return fileParams;
|
|
34
|
+
}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
export class OpenAPIToMCPConverter {
|
|
2
|
+
openApiSpec;
|
|
3
|
+
schemaCache = {};
|
|
4
|
+
nameCounter = 0;
|
|
5
|
+
constructor(openApiSpec) {
|
|
6
|
+
this.openApiSpec = openApiSpec;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a $ref reference to its schema in the openApiSpec.
|
|
10
|
+
* Returns the raw OpenAPI SchemaObject or null if not found.
|
|
11
|
+
*/
|
|
12
|
+
internalResolveRef(ref, resolvedRefs) {
|
|
13
|
+
if (!ref.startsWith("#/")) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (resolvedRefs.has(ref)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const parts = ref.replace(/^#\//, "").split("/");
|
|
20
|
+
let current = this.openApiSpec;
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
current = current[part];
|
|
23
|
+
if (!current)
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
resolvedRefs.add(ref);
|
|
27
|
+
return current;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Convert an OpenAPI schema (or reference) into a JSON Schema object.
|
|
31
|
+
* Uses caching and handles cycles by returning $ref nodes.
|
|
32
|
+
*/
|
|
33
|
+
convertOpenApiSchemaToJsonSchema(schema, resolvedRefs, resolveRefs = false) {
|
|
34
|
+
if ("$ref" in schema) {
|
|
35
|
+
const ref = schema.$ref;
|
|
36
|
+
if (!resolveRefs) {
|
|
37
|
+
if (ref.startsWith("#/components/schemas/")) {
|
|
38
|
+
return {
|
|
39
|
+
$ref: ref.replace(/^#\/components\/schemas\//, "#/$defs/"),
|
|
40
|
+
...("description" in schema
|
|
41
|
+
? { description: schema.description }
|
|
42
|
+
: {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
console.error(`Attempting to resolve ref ${ref} not found in components collection.`);
|
|
46
|
+
// deliberate fall through
|
|
47
|
+
}
|
|
48
|
+
// Create base schema with $ref and description if present
|
|
49
|
+
const refSchema = { $ref: ref };
|
|
50
|
+
if ("description" in schema && schema.description) {
|
|
51
|
+
refSchema.description = schema.description;
|
|
52
|
+
}
|
|
53
|
+
// If already cached, return immediately with description
|
|
54
|
+
if (this.schemaCache[ref]) {
|
|
55
|
+
return this.schemaCache[ref];
|
|
56
|
+
}
|
|
57
|
+
const resolved = this.internalResolveRef(ref, resolvedRefs);
|
|
58
|
+
if (!resolved) {
|
|
59
|
+
// TODO: need extensive tests for this and we definitely need to handle the case of self references
|
|
60
|
+
console.error(`Failed to resolve ref ${ref}`);
|
|
61
|
+
return {
|
|
62
|
+
$ref: ref.replace(/^#\/components\/schemas\//, "#/$defs/"),
|
|
63
|
+
description: "description" in schema
|
|
64
|
+
? (schema.description ?? "")
|
|
65
|
+
: "",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const converted = this.convertOpenApiSchemaToJsonSchema(resolved, resolvedRefs, resolveRefs);
|
|
70
|
+
this.schemaCache[ref] = converted;
|
|
71
|
+
return converted;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Handle inline schema
|
|
75
|
+
const result = {};
|
|
76
|
+
if (schema.type) {
|
|
77
|
+
result.type = schema.type;
|
|
78
|
+
}
|
|
79
|
+
// Convert binary format to uri-reference and enhance description
|
|
80
|
+
if (schema.format === "binary") {
|
|
81
|
+
result.format = "uri-reference";
|
|
82
|
+
const binaryDesc = "absolute paths to local files";
|
|
83
|
+
result.description = schema.description
|
|
84
|
+
? `${schema.description} (${binaryDesc})`
|
|
85
|
+
: binaryDesc;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (schema.format) {
|
|
89
|
+
result.format = schema.format;
|
|
90
|
+
}
|
|
91
|
+
if (schema.description) {
|
|
92
|
+
result.description = schema.description;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (schema.enum) {
|
|
96
|
+
result.enum = schema.enum;
|
|
97
|
+
}
|
|
98
|
+
if (schema.default !== undefined) {
|
|
99
|
+
result.default = schema.default;
|
|
100
|
+
}
|
|
101
|
+
// Handle object properties
|
|
102
|
+
if (schema.type === "object") {
|
|
103
|
+
result.type = "object";
|
|
104
|
+
if (schema.properties) {
|
|
105
|
+
result.properties = {};
|
|
106
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
107
|
+
result.properties[name] = this.convertOpenApiSchemaToJsonSchema(propSchema, resolvedRefs, resolveRefs);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (schema.required) {
|
|
111
|
+
result.required = schema.required;
|
|
112
|
+
}
|
|
113
|
+
if (schema.additionalProperties === true ||
|
|
114
|
+
schema.additionalProperties === undefined) {
|
|
115
|
+
result.additionalProperties = true;
|
|
116
|
+
}
|
|
117
|
+
else if (schema.additionalProperties &&
|
|
118
|
+
typeof schema.additionalProperties === "object") {
|
|
119
|
+
result.additionalProperties = this.convertOpenApiSchemaToJsonSchema(schema.additionalProperties, resolvedRefs, resolveRefs);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
result.additionalProperties = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Handle arrays - ensure binary format conversion happens for array items too
|
|
126
|
+
if (schema.type === "array" && schema.items) {
|
|
127
|
+
result.type = "array";
|
|
128
|
+
result.items = this.convertOpenApiSchemaToJsonSchema(schema.items, resolvedRefs, resolveRefs);
|
|
129
|
+
}
|
|
130
|
+
// oneOf, anyOf, allOf
|
|
131
|
+
if (schema.oneOf) {
|
|
132
|
+
result.oneOf = schema.oneOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs));
|
|
133
|
+
}
|
|
134
|
+
if (schema.anyOf) {
|
|
135
|
+
result.anyOf = schema.anyOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs));
|
|
136
|
+
}
|
|
137
|
+
if (schema.allOf) {
|
|
138
|
+
result.allOf = schema.allOf.map((s) => this.convertOpenApiSchemaToJsonSchema(s, resolvedRefs, resolveRefs));
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
convertToMCPTools() {
|
|
143
|
+
const apiName = "API";
|
|
144
|
+
const openApiLookup = {};
|
|
145
|
+
const tools = {
|
|
146
|
+
[apiName]: { methods: [] },
|
|
147
|
+
};
|
|
148
|
+
const zip = {};
|
|
149
|
+
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
|
|
150
|
+
if (!pathItem)
|
|
151
|
+
continue;
|
|
152
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
153
|
+
if (!this.isOperation(method, operation))
|
|
154
|
+
continue;
|
|
155
|
+
const mcpMethod = this.convertOperationToMCPMethod(operation, method, path);
|
|
156
|
+
if (mcpMethod) {
|
|
157
|
+
const uniqueName = this.ensureUniqueName(mcpMethod.name);
|
|
158
|
+
mcpMethod.name = uniqueName;
|
|
159
|
+
tools[apiName].methods.push(mcpMethod);
|
|
160
|
+
openApiLookup[apiName + "-" + uniqueName] = {
|
|
161
|
+
...operation,
|
|
162
|
+
method,
|
|
163
|
+
path,
|
|
164
|
+
};
|
|
165
|
+
zip[apiName + "-" + uniqueName] = {
|
|
166
|
+
openApi: { ...operation, method, path },
|
|
167
|
+
mcp: mcpMethod,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { tools, openApiLookup, zip };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Convert the OpenAPI spec to OpenAI's ChatCompletionTool format
|
|
176
|
+
*/
|
|
177
|
+
convertToOpenAITools() {
|
|
178
|
+
const tools = [];
|
|
179
|
+
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
|
|
180
|
+
if (!pathItem)
|
|
181
|
+
continue;
|
|
182
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
183
|
+
if (!this.isOperation(method, operation))
|
|
184
|
+
continue;
|
|
185
|
+
const parameters = this.convertOperationToJsonSchema(operation, method, path);
|
|
186
|
+
const tool = {
|
|
187
|
+
type: "function",
|
|
188
|
+
function: {
|
|
189
|
+
name: operation.operationId,
|
|
190
|
+
description: operation.summary || operation.description || "",
|
|
191
|
+
parameters: parameters,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
tools.push(tool);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return tools;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Convert the OpenAPI spec to Anthropic's Tool format
|
|
201
|
+
*/
|
|
202
|
+
convertToAnthropicTools() {
|
|
203
|
+
const tools = [];
|
|
204
|
+
for (const [path, pathItem] of Object.entries(this.openApiSpec.paths || {})) {
|
|
205
|
+
if (!pathItem)
|
|
206
|
+
continue;
|
|
207
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
208
|
+
if (!this.isOperation(method, operation))
|
|
209
|
+
continue;
|
|
210
|
+
const parameters = this.convertOperationToJsonSchema(operation, method, path);
|
|
211
|
+
const tool = {
|
|
212
|
+
name: operation.operationId,
|
|
213
|
+
description: operation.summary || operation.description || "",
|
|
214
|
+
input_schema: parameters,
|
|
215
|
+
};
|
|
216
|
+
tools.push(tool);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return tools;
|
|
220
|
+
}
|
|
221
|
+
convertComponentsToJsonSchema() {
|
|
222
|
+
const components = this.openApiSpec.components || {};
|
|
223
|
+
const schema = {};
|
|
224
|
+
for (const [key, value] of Object.entries(components.schemas || {})) {
|
|
225
|
+
schema[key] = this.convertOpenApiSchemaToJsonSchema(value, new Set());
|
|
226
|
+
}
|
|
227
|
+
return schema;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Helper method to convert an operation to a JSON Schema for parameters
|
|
231
|
+
*/
|
|
232
|
+
convertOperationToJsonSchema(operation, method, path) {
|
|
233
|
+
const schema = {
|
|
234
|
+
type: "object",
|
|
235
|
+
properties: {},
|
|
236
|
+
required: [],
|
|
237
|
+
$defs: this.convertComponentsToJsonSchema(),
|
|
238
|
+
};
|
|
239
|
+
// Handle parameters (path, query, header, cookie)
|
|
240
|
+
if (operation.parameters) {
|
|
241
|
+
for (const param of operation.parameters) {
|
|
242
|
+
const paramObj = this.resolveParameter(param);
|
|
243
|
+
if (paramObj && paramObj.schema) {
|
|
244
|
+
const paramSchema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set());
|
|
245
|
+
// Merge parameter-level description if available
|
|
246
|
+
if (paramObj.description) {
|
|
247
|
+
paramSchema.description = paramObj.description;
|
|
248
|
+
}
|
|
249
|
+
schema.properties[paramObj.name] = paramSchema;
|
|
250
|
+
if (paramObj.required) {
|
|
251
|
+
schema.required.push(paramObj.name);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Handle requestBody
|
|
257
|
+
if (operation.requestBody) {
|
|
258
|
+
const bodyObj = this.resolveRequestBody(operation.requestBody);
|
|
259
|
+
if (bodyObj?.content) {
|
|
260
|
+
if (bodyObj.content["application/json"]?.schema) {
|
|
261
|
+
const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content["application/json"].schema, new Set());
|
|
262
|
+
if (bodySchema.type === "object" && bodySchema.properties) {
|
|
263
|
+
for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
|
|
264
|
+
schema.properties[name] = propSchema;
|
|
265
|
+
}
|
|
266
|
+
if (bodySchema.required) {
|
|
267
|
+
schema.required.push(...bodySchema.required);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return schema;
|
|
274
|
+
}
|
|
275
|
+
isOperation(method, operation) {
|
|
276
|
+
return ["get", "post", "put", "delete", "patch"].includes(method.toLowerCase());
|
|
277
|
+
}
|
|
278
|
+
isParameterObject(param) {
|
|
279
|
+
return !("$ref" in param);
|
|
280
|
+
}
|
|
281
|
+
isRequestBodyObject(body) {
|
|
282
|
+
return !("$ref" in body);
|
|
283
|
+
}
|
|
284
|
+
resolveParameter(param) {
|
|
285
|
+
if (this.isParameterObject(param)) {
|
|
286
|
+
return param;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const resolved = this.internalResolveRef(param.$ref, new Set());
|
|
290
|
+
if (resolved && resolved.name) {
|
|
291
|
+
return resolved;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
resolveRequestBody(body) {
|
|
297
|
+
if (this.isRequestBodyObject(body)) {
|
|
298
|
+
return body;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
const resolved = this.internalResolveRef(body.$ref, new Set());
|
|
302
|
+
if (resolved) {
|
|
303
|
+
return resolved;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
resolveResponse(response) {
|
|
309
|
+
if ("$ref" in response) {
|
|
310
|
+
const resolved = this.internalResolveRef(response.$ref, new Set());
|
|
311
|
+
if (resolved) {
|
|
312
|
+
return resolved;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return response;
|
|
319
|
+
}
|
|
320
|
+
convertOperationToMCPMethod(operation, method, path) {
|
|
321
|
+
if (!operation.operationId) {
|
|
322
|
+
console.warn(`Operation without operationId at ${method} ${path}`);
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
const methodName = operation.operationId.replaceAll("\.", "_");
|
|
326
|
+
const inputSchema = {
|
|
327
|
+
$defs: this.convertComponentsToJsonSchema(),
|
|
328
|
+
type: "object",
|
|
329
|
+
properties: {},
|
|
330
|
+
required: [],
|
|
331
|
+
};
|
|
332
|
+
// Handle parameters (path, query, header, cookie)
|
|
333
|
+
if (operation.parameters) {
|
|
334
|
+
for (const param of operation.parameters) {
|
|
335
|
+
const paramObj = this.resolveParameter(param);
|
|
336
|
+
if (paramObj && paramObj.schema) {
|
|
337
|
+
const schema = this.convertOpenApiSchemaToJsonSchema(paramObj.schema, new Set(), false);
|
|
338
|
+
// Merge parameter-level description if available
|
|
339
|
+
if (paramObj.description) {
|
|
340
|
+
schema.description = paramObj.description;
|
|
341
|
+
}
|
|
342
|
+
inputSchema.properties[paramObj.name] = schema;
|
|
343
|
+
if (paramObj.required) {
|
|
344
|
+
inputSchema.required.push(paramObj.name);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Handle requestBody
|
|
350
|
+
if (operation.requestBody) {
|
|
351
|
+
const bodyObj = this.resolveRequestBody(operation.requestBody);
|
|
352
|
+
if (bodyObj?.content) {
|
|
353
|
+
// Handle multipart/form-data for file uploads
|
|
354
|
+
// We convert the multipart/form-data schema to a JSON schema and we require
|
|
355
|
+
// that the user passes in a string for each file that points to the local file
|
|
356
|
+
if (bodyObj.content["multipart/form-data"]?.schema) {
|
|
357
|
+
const formSchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content["multipart/form-data"].schema, new Set(), false);
|
|
358
|
+
if (formSchema.type === "object" && formSchema.properties) {
|
|
359
|
+
for (const [name, propSchema] of Object.entries(formSchema.properties)) {
|
|
360
|
+
inputSchema.properties[name] = propSchema;
|
|
361
|
+
}
|
|
362
|
+
if (formSchema.required) {
|
|
363
|
+
inputSchema.required.push(...formSchema.required);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Handle application/json
|
|
368
|
+
else if (bodyObj.content["application/json"]?.schema) {
|
|
369
|
+
const bodySchema = this.convertOpenApiSchemaToJsonSchema(bodyObj.content["application/json"].schema, new Set(), false);
|
|
370
|
+
// Merge body schema into the inputSchema's properties
|
|
371
|
+
if (bodySchema.type === "object" && bodySchema.properties) {
|
|
372
|
+
for (const [name, propSchema] of Object.entries(bodySchema.properties)) {
|
|
373
|
+
inputSchema.properties[name] = propSchema;
|
|
374
|
+
}
|
|
375
|
+
if (bodySchema.required) {
|
|
376
|
+
inputSchema.required.push(...bodySchema.required);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
// If the request body is not an object, just put it under "body"
|
|
381
|
+
inputSchema.properties["body"] = bodySchema;
|
|
382
|
+
inputSchema.required.push("body");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Build description including error responses
|
|
388
|
+
let description = operation.summary || operation.description || "";
|
|
389
|
+
if (operation.responses) {
|
|
390
|
+
const errorResponses = Object.entries(operation.responses)
|
|
391
|
+
.filter(([code]) => code.startsWith("4") || code.startsWith("5"))
|
|
392
|
+
.map(([code, response]) => {
|
|
393
|
+
const responseObj = this.resolveResponse(response);
|
|
394
|
+
let errorDesc = responseObj?.description || "";
|
|
395
|
+
return `${code}: ${errorDesc}`;
|
|
396
|
+
});
|
|
397
|
+
if (errorResponses.length > 0) {
|
|
398
|
+
description += "\nError Responses:\n" + errorResponses.join("\n");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Extract return type (response schema)
|
|
402
|
+
const returnSchema = this.extractResponseType(operation.responses);
|
|
403
|
+
// Generate Zod schema from input schema
|
|
404
|
+
try {
|
|
405
|
+
// const zodSchemaStr = jsonSchemaToZod(inputSchema, { module: "cjs" })
|
|
406
|
+
// console.log(zodSchemaStr)
|
|
407
|
+
// // Execute the function with the zod instance
|
|
408
|
+
// const zodSchema = eval(zodSchemaStr) as z.ZodType
|
|
409
|
+
return {
|
|
410
|
+
name: methodName,
|
|
411
|
+
description,
|
|
412
|
+
inputSchema,
|
|
413
|
+
...(returnSchema ? { returnSchema } : {}),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
console.warn(`Failed to generate Zod schema for ${methodName}:`, error);
|
|
418
|
+
// Fallback to a basic object schema
|
|
419
|
+
return {
|
|
420
|
+
name: methodName,
|
|
421
|
+
description,
|
|
422
|
+
inputSchema,
|
|
423
|
+
...(returnSchema ? { returnSchema } : {}),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
extractResponseType(responses) {
|
|
428
|
+
// Look for a success response
|
|
429
|
+
const successResponse = responses?.["200"] ||
|
|
430
|
+
responses?.["201"] ||
|
|
431
|
+
responses?.["202"] ||
|
|
432
|
+
responses?.["204"];
|
|
433
|
+
if (!successResponse)
|
|
434
|
+
return null;
|
|
435
|
+
const responseObj = this.resolveResponse(successResponse);
|
|
436
|
+
if (!responseObj || !responseObj.content)
|
|
437
|
+
return null;
|
|
438
|
+
if (responseObj.content["application/json"]?.schema) {
|
|
439
|
+
const returnSchema = this.convertOpenApiSchemaToJsonSchema(responseObj.content["application/json"].schema, new Set(), false);
|
|
440
|
+
returnSchema["$defs"] = this.convertComponentsToJsonSchema();
|
|
441
|
+
// Preserve the response description if available and not already set
|
|
442
|
+
if (responseObj.description && !returnSchema.description) {
|
|
443
|
+
returnSchema.description = responseObj.description;
|
|
444
|
+
}
|
|
445
|
+
return returnSchema;
|
|
446
|
+
}
|
|
447
|
+
// If no JSON response, fallback to a generic string or known formats
|
|
448
|
+
if (responseObj.content["image/png"] || responseObj.content["image/jpeg"]) {
|
|
449
|
+
return {
|
|
450
|
+
type: "string",
|
|
451
|
+
format: "binary",
|
|
452
|
+
description: responseObj.description || "",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
// Fallback
|
|
456
|
+
return { type: "string", description: responseObj.description || "" };
|
|
457
|
+
}
|
|
458
|
+
ensureUniqueName(name) {
|
|
459
|
+
if (name.length <= 64) {
|
|
460
|
+
return name;
|
|
461
|
+
}
|
|
462
|
+
const truncatedName = name.slice(0, 64 - 5); // Reserve space for suffix
|
|
463
|
+
const uniqueSuffix = this.generateUniqueSuffix();
|
|
464
|
+
return `${truncatedName}-${uniqueSuffix}`;
|
|
465
|
+
}
|
|
466
|
+
generateUniqueSuffix() {
|
|
467
|
+
this.nameCounter += 1;
|
|
468
|
+
return this.nameCounter.toString().padStart(4, "0");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { MCPProxy, } from "../openapi-mcp-server/mcp/proxy.js";
|
|
2
|
+
import swagger2openapi from "swagger2openapi";
|
|
3
|
+
const getOpenApiSpec = async (schemaUrl) => {
|
|
4
|
+
try {
|
|
5
|
+
const response = await fetch(schemaUrl);
|
|
6
|
+
if (!response.ok) {
|
|
7
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
8
|
+
}
|
|
9
|
+
const openApiV2Spec = await response.json();
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
swagger2openapi.convertObj(openApiV2Spec, {}, (err, options) => {
|
|
12
|
+
if (err) {
|
|
13
|
+
reject(err);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
resolve(options.openapi);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
console.error("Failed to parse OpenAPI spec:", error.message);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const unsupportedPaths = [
|
|
27
|
+
'/v1.0/{organization_name}/{project_name}/batch-runs/{batch_run_number}/screenshots/',
|
|
28
|
+
'/v1.0/{organization_name}/{project_name}/screenshots/{batch_task_id}/',
|
|
29
|
+
'/v1.0/magicpod-clients/api/{os}/{tag_or_version}/',
|
|
30
|
+
'/v1.0/magicpod-clients/local/{os}/{version}/'
|
|
31
|
+
];
|
|
32
|
+
export const initMagicPodApiProxy = async (baseUrl, apiToken, tools) => {
|
|
33
|
+
const schemaUrl = `${baseUrl}/api/v1.0/doc/?format=openapi`;
|
|
34
|
+
const openApiSpec = await getOpenApiSpec(schemaUrl);
|
|
35
|
+
openApiSpec.servers = [{ url: `${baseUrl}/api` }];
|
|
36
|
+
for (const path of Object.keys(openApiSpec.paths)) {
|
|
37
|
+
if (unsupportedPaths.includes(path)) {
|
|
38
|
+
delete openApiSpec.paths[path];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const proxy = new MCPProxy("magicpod-mcp-server", openApiSpec, apiToken, tools);
|
|
42
|
+
return proxy;
|
|
43
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const makeRequest = async (articleId, locale) => {
|
|
3
|
+
const headers = {
|
|
4
|
+
Accept: "application/json",
|
|
5
|
+
};
|
|
6
|
+
try {
|
|
7
|
+
const url = `https://trident-qa.zendesk.com/api/v2/help_center/${locale}/articles/${articleId}.json`;
|
|
8
|
+
const response = await fetch(url, { headers });
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
11
|
+
}
|
|
12
|
+
return await response.json();
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
console.error("Error making request:", error);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
export const readMagicpodArticle = () => {
|
|
20
|
+
return {
|
|
21
|
+
name: "read-magicpod-article",
|
|
22
|
+
description: "Read a specified article on MagicPod help center",
|
|
23
|
+
inputSchema: z.object({
|
|
24
|
+
articleId: z
|
|
25
|
+
.string()
|
|
26
|
+
.describe("An article ID of MagicPod Help Center, which can be retrieved by 'search-magicpod-articles' tool"),
|
|
27
|
+
locale: z
|
|
28
|
+
.union([z.literal("ja"), z.literal("en-us")])
|
|
29
|
+
.describe("Article's language"),
|
|
30
|
+
}),
|
|
31
|
+
handleRequest: async ({ articleId, locale }) => {
|
|
32
|
+
const response = await makeRequest(articleId, locale);
|
|
33
|
+
return {
|
|
34
|
+
content: [
|
|
35
|
+
{
|
|
36
|
+
type: "text",
|
|
37
|
+
text: JSON.stringify(response),
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const makeRequest = async (query, locale) => {
|
|
3
|
+
const headers = {
|
|
4
|
+
Accept: "application/json",
|
|
5
|
+
};
|
|
6
|
+
try {
|
|
7
|
+
const url = `https://trident-qa.zendesk.com/api/v2/help_center/articles/search.json?query=${query}&locale=${locale}`;
|
|
8
|
+
const response = await fetch(url, { headers });
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
11
|
+
}
|
|
12
|
+
return await response.json();
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
console.error("Error making request:", error);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
export const searchMagicpodArticles = () => {
|
|
20
|
+
return {
|
|
21
|
+
name: "search-magicpod-articles",
|
|
22
|
+
description: "Search the list of articles on MagicPod help center by specified keywords",
|
|
23
|
+
inputSchema: z.object({
|
|
24
|
+
query: z
|
|
25
|
+
.string()
|
|
26
|
+
.describe("Queries to search MagicPod Help Center's articles, split by whitespaces"),
|
|
27
|
+
locale: z
|
|
28
|
+
.union([z.literal("ja"), z.literal("en-us")])
|
|
29
|
+
.describe("Query's and search target's locale"),
|
|
30
|
+
}),
|
|
31
|
+
handleRequest: async ({ query, locale }) => {
|
|
32
|
+
const response = await makeRequest(query, locale);
|
|
33
|
+
// The response has "body" field, but it is too large for LLM
|
|
34
|
+
// So, such large or insignificant fields are filtered here
|
|
35
|
+
response.results = response.results.map((r) => ({
|
|
36
|
+
id: r.id,
|
|
37
|
+
title: r.title,
|
|
38
|
+
content_tag_ids: r.content_tag_ids,
|
|
39
|
+
label_names: r.label_names,
|
|
40
|
+
}));
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: JSON.stringify(response),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "magicpod-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model Context Protocol server for MagicPod integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"magicpod-mcp-server": "./build/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc && chmod 755 build/index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"magicpod",
|
|
15
|
+
"test-automation",
|
|
16
|
+
"model-context-protocol"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Magic-Pod/magicpod-mcp-server.git"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"build"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@anthropic-ai/sdk": "^0.33.1",
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.9.0",
|
|
31
|
+
"@types/mustache": "^4.2.5",
|
|
32
|
+
"commander": "^13.1.0",
|
|
33
|
+
"json-schema": "^0.4.0",
|
|
34
|
+
"mustache": "^4.2.0",
|
|
35
|
+
"openai": "^4.91.1",
|
|
36
|
+
"openapi-client-axios": "^7.5.5",
|
|
37
|
+
"openapi-schema-validator": "^12.1.3",
|
|
38
|
+
"openapi-types": "^12.1.3",
|
|
39
|
+
"swagger2openapi": "^7.0.8",
|
|
40
|
+
"zod": "^3.24.2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/json-schema": "^7.0.15",
|
|
44
|
+
"@types/node": "^22.14.0",
|
|
45
|
+
"@types/swagger2openapi": "^7.0.4",
|
|
46
|
+
"prettier": "^3.5.3",
|
|
47
|
+
"typescript": "^5.8.3"
|
|
48
|
+
}
|
|
49
|
+
}
|