openpets 1.0.10 → 1.0.12
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/dist/data/api.json +3758 -7222
- package/dist/src/core/build-pet.d.ts.map +1 -1
- package/dist/src/core/build-pet.js +7 -0
- package/dist/src/core/build-pet.js.map +1 -1
- package/dist/src/core/cli.js +456 -130
- package/dist/src/core/cli.js.map +1 -1
- package/dist/src/core/ensure-npmignore.d.ts +30 -0
- package/dist/src/core/ensure-npmignore.d.ts.map +1 -0
- package/dist/src/core/ensure-npmignore.js +121 -0
- package/dist/src/core/ensure-npmignore.js.map +1 -0
- package/dist/src/core/index.d.ts +6 -3
- package/dist/src/core/index.d.ts.map +1 -1
- package/dist/src/core/index.js +9 -3
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/core/mcp-generator.d.ts +56 -0
- package/dist/src/core/mcp-generator.d.ts.map +1 -0
- package/dist/src/core/mcp-generator.js +1438 -0
- package/dist/src/core/mcp-generator.js.map +1 -0
- package/dist/src/core/mcp-server.js +0 -0
- package/dist/src/core/openapi-generator.d.ts +59 -0
- package/dist/src/core/openapi-generator.d.ts.map +1 -0
- package/dist/src/core/openapi-generator.js +800 -0
- package/dist/src/core/openapi-generator.js.map +1 -0
- package/dist/src/core/pet-config.d.ts +107 -49
- package/dist/src/core/pet-config.d.ts.map +1 -1
- package/dist/src/core/pet-config.js +6 -4
- package/dist/src/core/pet-config.js.map +1 -1
- package/dist/src/core/pet-downloader.d.ts +16 -0
- package/dist/src/core/pet-downloader.d.ts.map +1 -1
- package/dist/src/core/pet-downloader.js +145 -3
- package/dist/src/core/pet-downloader.js.map +1 -1
- package/dist/src/core/publish-pet.d.ts +29 -0
- package/dist/src/core/publish-pet.d.ts.map +1 -0
- package/dist/src/core/publish-pet.js +372 -0
- package/dist/src/core/publish-pet.js.map +1 -0
- package/dist/src/core/sdk-generator.d.ts +92 -0
- package/dist/src/core/sdk-generator.d.ts.map +1 -0
- package/dist/src/core/sdk-generator.js +567 -0
- package/dist/src/core/sdk-generator.js.map +1 -0
- package/dist/src/core/search-pets.d.ts +5 -0
- package/dist/src/core/search-pets.d.ts.map +1 -1
- package/dist/src/core/search-pets.js +43 -0
- package/dist/src/core/search-pets.js.map +1 -1
- package/dist/src/core/security-scanner.d.ts +49 -0
- package/dist/src/core/security-scanner.d.ts.map +1 -0
- package/dist/src/core/security-scanner.js +255 -0
- package/dist/src/core/security-scanner.js.map +1 -0
- package/dist/src/core/tool-lister.d.ts +61 -0
- package/dist/src/core/tool-lister.d.ts.map +1 -0
- package/dist/src/core/tool-lister.js +333 -0
- package/dist/src/core/tool-lister.js.map +1 -0
- package/dist/src/core/validate-pet.d.ts +2 -0
- package/dist/src/core/validate-pet.d.ts.map +1 -1
- package/dist/src/core/validate-pet.js +93 -1
- package/dist/src/core/validate-pet.js.map +1 -1
- package/dist/src/sdk/plugin-factory.d.ts +86 -0
- package/dist/src/sdk/plugin-factory.d.ts.map +1 -1
- package/dist/src/sdk/plugin-factory.js +450 -53
- package/dist/src/sdk/plugin-factory.js.map +1 -1
- package/dist/src/sdk/prompts-manager.d.ts +6 -0
- package/dist/src/sdk/prompts-manager.d.ts.map +1 -0
- package/dist/src/sdk/prompts-manager.js +162 -0
- package/dist/src/sdk/prompts-manager.js.map +1 -0
- package/package.json +1 -1
- package/dist/src/core/local-cache.d.ts +0 -69
- package/dist/src/core/local-cache.d.ts.map +0 -1
- package/dist/src/core/local-cache.js +0 -212
- package/dist/src/core/local-cache.js.map +0 -1
- package/dist/src/core/plugin-factory.d.ts +0 -58
- package/dist/src/core/plugin-factory.d.ts.map +0 -1
- package/dist/src/core/plugin-factory.js +0 -212
- package/dist/src/core/plugin-factory.js.map +0 -1
|
@@ -0,0 +1,1438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Tool Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates OpenPets-compatible tool definitions from a remote MCP server.
|
|
5
|
+
* Similar to how hey-api generates TypeScript clients from OpenAPI specs.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* pets generate-mcp # Run from inside a pet directory with mcpServer in package.json
|
|
9
|
+
* pets generate-mcp --output mcp-tools.ts # Custom output file
|
|
10
|
+
*/
|
|
11
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
13
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
14
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
16
|
+
import { resolve, basename } from "path";
|
|
17
|
+
import { config as loadDotenv } from "dotenv";
|
|
18
|
+
import { createLogger } from "./logger";
|
|
19
|
+
const logger = createLogger("mcp-generator");
|
|
20
|
+
function loadMCPConfigFromPackageJson(dir) {
|
|
21
|
+
const packageJsonPath = resolve(dir, "package.json");
|
|
22
|
+
if (!existsSync(packageJsonPath)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
27
|
+
if (!packageJson.mcpServer) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// Extract pet name from package name or directory
|
|
31
|
+
const petName = packageJson.name?.replace(/^@openpets\//, "") || basename(dir);
|
|
32
|
+
// Load .env files
|
|
33
|
+
loadDotenv({ path: resolve(dir, ".env") });
|
|
34
|
+
loadDotenv({ path: resolve(dir, "../.env") });
|
|
35
|
+
loadDotenv({ path: resolve(dir, "../../.env") });
|
|
36
|
+
// Get auth token from environment
|
|
37
|
+
const envVarName = `${petName.toUpperCase().replace(/-/g, "_")}_ACCESS_TOKEN`;
|
|
38
|
+
const authToken = process.env[envVarName];
|
|
39
|
+
// Check for custom auth env var name, otherwise derive from pet name
|
|
40
|
+
const customAuthEnvVar = packageJson.mcpServer.authEnvVar;
|
|
41
|
+
const authEnvVarName = customAuthEnvVar || envVarName;
|
|
42
|
+
const resolvedAuthToken = process.env[authEnvVarName] || authToken;
|
|
43
|
+
return {
|
|
44
|
+
config: {
|
|
45
|
+
url: packageJson.mcpServer.url,
|
|
46
|
+
name: packageJson.mcpServer.name || petName,
|
|
47
|
+
version: packageJson.mcpServer.version,
|
|
48
|
+
npmPackage: packageJson.mcpServer.npmPackage,
|
|
49
|
+
stdioCommand: packageJson.mcpServer.stdioCommand,
|
|
50
|
+
stdioArgs: packageJson.mcpServer.stdioArgs,
|
|
51
|
+
transport: packageJson.mcpServer.transport,
|
|
52
|
+
dockerImage: packageJson.mcpServer.dockerImage,
|
|
53
|
+
dockerEnvVar: packageJson.mcpServer.dockerEnvVar,
|
|
54
|
+
authHeader: packageJson.mcpServer.authHeader,
|
|
55
|
+
authEnvVar: authEnvVarName,
|
|
56
|
+
stdioEnvVar: packageJson.mcpServer.stdioEnvVar,
|
|
57
|
+
},
|
|
58
|
+
petName,
|
|
59
|
+
authToken: resolvedAuthToken,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
logger.error(`Error reading package.json: ${error.message}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function connectToMCPServer(config, authToken) {
|
|
68
|
+
const client = new Client({
|
|
69
|
+
name: "openpets-generator",
|
|
70
|
+
version: "1.0.0",
|
|
71
|
+
});
|
|
72
|
+
let transport;
|
|
73
|
+
switch (config.transport) {
|
|
74
|
+
case "sse-remote": {
|
|
75
|
+
// Use mcp-remote for OAuth-based remote MCP servers (like Asana)
|
|
76
|
+
// This will open a browser for OAuth authentication
|
|
77
|
+
logger.debug(`Connecting via mcp-remote to: ${config.url}`);
|
|
78
|
+
console.log(`\n🔐 OAuth authentication required. A browser window will open...`);
|
|
79
|
+
transport = new StdioClientTransport({
|
|
80
|
+
command: "bunx",
|
|
81
|
+
args: ["mcp-remote", config.url],
|
|
82
|
+
});
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "docker": {
|
|
86
|
+
// Use Docker container with environment variable authentication
|
|
87
|
+
// Example: GitHub MCP server at ghcr.io/github/github-mcp-server
|
|
88
|
+
const dockerImage = config.dockerImage || `ghcr.io/${config.name}/${config.name}-mcp-server`;
|
|
89
|
+
// dockerEnvVar is what the container expects, authEnvVar is what we read from .env
|
|
90
|
+
const containerEnvVar = config.dockerEnvVar || config.authEnvVar || `${config.name?.toUpperCase().replace(/-/g, "_")}_PERSONAL_ACCESS_TOKEN`;
|
|
91
|
+
const sourceEnvVar = config.authEnvVar || containerEnvVar;
|
|
92
|
+
if (!authToken) {
|
|
93
|
+
throw new Error(`Missing ${sourceEnvVar} environment variable for Docker transport`);
|
|
94
|
+
}
|
|
95
|
+
logger.debug(`Connecting via Docker: ${dockerImage}`);
|
|
96
|
+
console.log(`\n🐳 Connecting to Docker MCP server: ${dockerImage}`);
|
|
97
|
+
transport = new StdioClientTransport({
|
|
98
|
+
command: "docker",
|
|
99
|
+
args: [
|
|
100
|
+
"run", "-i", "--rm",
|
|
101
|
+
"-e", containerEnvVar,
|
|
102
|
+
dockerImage,
|
|
103
|
+
],
|
|
104
|
+
env: {
|
|
105
|
+
...process.env,
|
|
106
|
+
[containerEnvVar]: authToken,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "http": {
|
|
112
|
+
// Use HTTP/SSE transport with Bearer token authentication
|
|
113
|
+
// Example: GitHub Copilot MCP at https://api.githubcopilot.com/mcp/
|
|
114
|
+
if (!config.url) {
|
|
115
|
+
throw new Error("URL is required for HTTP transport");
|
|
116
|
+
}
|
|
117
|
+
const headers = {};
|
|
118
|
+
if (authToken) {
|
|
119
|
+
const headerName = config.authHeader || "Authorization";
|
|
120
|
+
headers[headerName] = `Bearer ${authToken}`;
|
|
121
|
+
}
|
|
122
|
+
logger.debug(`Connecting via HTTP to: ${config.url}`);
|
|
123
|
+
console.log(`\n🌐 Connecting to HTTP MCP server: ${config.url}`);
|
|
124
|
+
// Try StreamableHTTP first, fall back to SSE
|
|
125
|
+
try {
|
|
126
|
+
transport = new StreamableHTTPClientTransport(new URL(config.url), {
|
|
127
|
+
requestInit: { headers },
|
|
128
|
+
});
|
|
129
|
+
await client.connect(transport);
|
|
130
|
+
logger.debug("Connected via Streamable HTTP transport");
|
|
131
|
+
}
|
|
132
|
+
catch (httpError) {
|
|
133
|
+
logger.debug(`Streamable HTTP failed: ${httpError.message}, trying SSE...`);
|
|
134
|
+
transport = new SSEClientTransport(new URL(config.url), {
|
|
135
|
+
requestInit: { headers },
|
|
136
|
+
});
|
|
137
|
+
await client.connect(transport);
|
|
138
|
+
logger.debug("Connected via SSE transport");
|
|
139
|
+
}
|
|
140
|
+
const toolsResponse = await client.listTools();
|
|
141
|
+
return { client, tools: toolsResponse.tools };
|
|
142
|
+
}
|
|
143
|
+
default: {
|
|
144
|
+
// Default: stdio transport with npm package and access token
|
|
145
|
+
const npmPackage = config.npmPackage || `@${config.name}/mcp-server`;
|
|
146
|
+
const args = config.stdioArgs ? [...config.stdioArgs] : [npmPackage];
|
|
147
|
+
// Build environment for the subprocess
|
|
148
|
+
const env = { ...process.env };
|
|
149
|
+
// Pass auth token either via command line arg or environment variable
|
|
150
|
+
if (authToken) {
|
|
151
|
+
if (config.stdioEnvVar) {
|
|
152
|
+
// Pass token via environment variable (e.g., NOTION_TOKEN for Notion MCP server)
|
|
153
|
+
env[config.stdioEnvVar] = authToken;
|
|
154
|
+
logger.debug(`Passing token via env var: ${config.stdioEnvVar}`);
|
|
155
|
+
}
|
|
156
|
+
else if (!args.some(arg => arg.includes('access-token'))) {
|
|
157
|
+
// Default: pass token via command line argument
|
|
158
|
+
args.push(`--access-token=${authToken}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const command = config.stdioCommand || "bunx";
|
|
162
|
+
logger.debug(`Connecting via stdio: ${command} ${args.join(" ")}`);
|
|
163
|
+
transport = new StdioClientTransport({
|
|
164
|
+
command,
|
|
165
|
+
args,
|
|
166
|
+
env,
|
|
167
|
+
});
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
await client.connect(transport);
|
|
172
|
+
const toolsResponse = await client.listTools();
|
|
173
|
+
return { client, tools: toolsResponse.tools };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Generate a verbose description for a nested object schema.
|
|
177
|
+
* This extracts property names, types, descriptions, and required fields
|
|
178
|
+
* to provide helpful context for JSON object parameters.
|
|
179
|
+
*/
|
|
180
|
+
function generateNestedObjectDescription(prop, maxDepth = 2) {
|
|
181
|
+
if (!prop || prop.type !== "object" || !prop.properties) {
|
|
182
|
+
return prop?.description || prop?.title || "JSON object";
|
|
183
|
+
}
|
|
184
|
+
const parts = [];
|
|
185
|
+
// Add base description if present
|
|
186
|
+
if (prop.description) {
|
|
187
|
+
parts.push(prop.description);
|
|
188
|
+
}
|
|
189
|
+
else if (prop.title) {
|
|
190
|
+
parts.push(prop.title);
|
|
191
|
+
}
|
|
192
|
+
const required = prop.required || [];
|
|
193
|
+
const propDescriptions = [];
|
|
194
|
+
for (const [name, schema] of Object.entries(prop.properties)) {
|
|
195
|
+
const isRequired = required.includes(name);
|
|
196
|
+
const requiredMarker = isRequired ? " (required)" : "";
|
|
197
|
+
let typeDesc = schema.type || "any";
|
|
198
|
+
// Handle enums
|
|
199
|
+
if (schema.enum && schema.enum.length > 0) {
|
|
200
|
+
const enumVals = schema.enum.slice(0, 5).map((v) => JSON.stringify(v)).join(", ");
|
|
201
|
+
const more = schema.enum.length > 5 ? `, ... (${schema.enum.length} total)` : "";
|
|
202
|
+
typeDesc = `enum: [${enumVals}${more}]`;
|
|
203
|
+
}
|
|
204
|
+
// Handle anyOf (nullable/union types)
|
|
205
|
+
if (schema.anyOf) {
|
|
206
|
+
const types = schema.anyOf
|
|
207
|
+
.map((s) => s.type || (s.enum ? "enum" : "any"))
|
|
208
|
+
.filter((t) => t !== "null");
|
|
209
|
+
typeDesc = types.join(" | ") || "any";
|
|
210
|
+
}
|
|
211
|
+
// Handle arrays
|
|
212
|
+
if (schema.type === "array" && schema.items) {
|
|
213
|
+
typeDesc = `array of ${schema.items.type || "objects"}`;
|
|
214
|
+
}
|
|
215
|
+
// Handle nested objects (but limit depth)
|
|
216
|
+
if (schema.type === "object" && schema.properties && maxDepth > 0) {
|
|
217
|
+
const nestedProps = Object.keys(schema.properties).slice(0, 5);
|
|
218
|
+
const more = Object.keys(schema.properties).length > 5 ? ", ..." : "";
|
|
219
|
+
typeDesc = `object with: {${nestedProps.join(", ")}${more}}`;
|
|
220
|
+
}
|
|
221
|
+
// Build property description
|
|
222
|
+
let propDesc = `${name}${requiredMarker}: ${typeDesc}`;
|
|
223
|
+
if (schema.description) {
|
|
224
|
+
propDesc += ` - ${schema.description.substring(0, 80)}${schema.description.length > 80 ? "..." : ""}`;
|
|
225
|
+
}
|
|
226
|
+
if (schema.default !== undefined) {
|
|
227
|
+
propDesc += ` (default: ${JSON.stringify(schema.default)})`;
|
|
228
|
+
}
|
|
229
|
+
propDescriptions.push(propDesc);
|
|
230
|
+
}
|
|
231
|
+
if (propDescriptions.length > 0) {
|
|
232
|
+
parts.push("Properties: " + propDescriptions.join("; "));
|
|
233
|
+
}
|
|
234
|
+
return parts.join(". ") || "JSON object";
|
|
235
|
+
}
|
|
236
|
+
function jsonSchemaToZod(prop) {
|
|
237
|
+
if (!prop)
|
|
238
|
+
return "z.any()";
|
|
239
|
+
const desc = prop.description
|
|
240
|
+
? `.describe(${JSON.stringify(prop.description)})`
|
|
241
|
+
: "";
|
|
242
|
+
// Handle anyOf (nullable/union types) - common in MCP schemas
|
|
243
|
+
if (prop.anyOf && Array.isArray(prop.anyOf)) {
|
|
244
|
+
// Filter out null type to find the actual type
|
|
245
|
+
const nonNullTypes = prop.anyOf.filter((s) => s.type !== "null");
|
|
246
|
+
if (nonNullTypes.length === 1) {
|
|
247
|
+
// Simple nullable type - use the non-null type
|
|
248
|
+
const actualType = nonNullTypes[0];
|
|
249
|
+
// Check for enum in the actual type
|
|
250
|
+
if (actualType.enum && actualType.enum.length > 0) {
|
|
251
|
+
// Limit enum values for readability
|
|
252
|
+
const enumVals = actualType.enum.slice(0, 10);
|
|
253
|
+
const enumValues = enumVals.map((v) => JSON.stringify(v)).join(", ");
|
|
254
|
+
const more = actualType.enum.length > 10 ? ` /* +${actualType.enum.length - 10} more */` : "";
|
|
255
|
+
return `z.enum([${enumValues}${more}])${desc}`;
|
|
256
|
+
}
|
|
257
|
+
// Recursively get the zod type for the actual type
|
|
258
|
+
return jsonSchemaToZod({ ...actualType, description: prop.description });
|
|
259
|
+
}
|
|
260
|
+
if (nonNullTypes.length > 1) {
|
|
261
|
+
// Multiple types - try to pick the most useful one
|
|
262
|
+
const stringType = nonNullTypes.find((t) => t.type === "string");
|
|
263
|
+
const arrayType = nonNullTypes.find((t) => t.type === "array");
|
|
264
|
+
if (stringType && arrayType) {
|
|
265
|
+
// Common pattern: string | string[] - use array of strings
|
|
266
|
+
if (arrayType.items?.type === "string") {
|
|
267
|
+
return `z.array(z.string())${desc}`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Fall back to any with description
|
|
271
|
+
return `z.any()${desc}`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
switch (prop.type) {
|
|
275
|
+
case "string":
|
|
276
|
+
if (prop.enum && prop.enum.length > 0) {
|
|
277
|
+
// Limit enum values for readability
|
|
278
|
+
const enumVals = prop.enum.slice(0, 10);
|
|
279
|
+
const enumValues = enumVals.map((v) => JSON.stringify(v)).join(", ");
|
|
280
|
+
const more = prop.enum.length > 10 ? ` /* +${prop.enum.length - 10} more */` : "";
|
|
281
|
+
return `z.enum([${enumValues}${more}])${desc}`;
|
|
282
|
+
}
|
|
283
|
+
return `z.string()${desc}`;
|
|
284
|
+
case "number":
|
|
285
|
+
case "integer":
|
|
286
|
+
return `z.number()${desc}`;
|
|
287
|
+
case "boolean":
|
|
288
|
+
return `z.boolean()${desc}`;
|
|
289
|
+
case "array":
|
|
290
|
+
if (prop.items?.type === "string") {
|
|
291
|
+
return `z.array(z.string())${desc}`;
|
|
292
|
+
}
|
|
293
|
+
else if (prop.items?.type === "number" || prop.items?.type === "integer") {
|
|
294
|
+
return `z.array(z.number())${desc}`;
|
|
295
|
+
}
|
|
296
|
+
// Check for enum items
|
|
297
|
+
if (prop.items?.enum && prop.items.enum.length > 0) {
|
|
298
|
+
const enumVals = prop.items.enum.slice(0, 10);
|
|
299
|
+
const enumValues = enumVals.map((v) => JSON.stringify(v)).join(", ");
|
|
300
|
+
const more = prop.items.enum.length > 10 ? ` /* +${prop.items.enum.length - 10} more */` : "";
|
|
301
|
+
return `z.array(z.enum([${enumValues}${more}]))${desc}`;
|
|
302
|
+
}
|
|
303
|
+
// For complex array items, use JSON string (OpenCode limitation)
|
|
304
|
+
const arrayDesc = prop.description
|
|
305
|
+
? `.describe(${JSON.stringify("JSON array: " + prop.description)})`
|
|
306
|
+
: '.describe("JSON array")';
|
|
307
|
+
return `z.string()${arrayDesc}`;
|
|
308
|
+
case "object":
|
|
309
|
+
// For nested objects, generate a verbose description that includes
|
|
310
|
+
// property names, types, and descriptions for better AI context
|
|
311
|
+
const verboseDesc = generateNestedObjectDescription(prop);
|
|
312
|
+
return `z.string().describe(${JSON.stringify("JSON object: " + verboseDesc)})`;
|
|
313
|
+
default:
|
|
314
|
+
return `z.any()${desc}`;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Flatten nested object properties to top-level with prefixed names.
|
|
319
|
+
* This allows OpenCode to see and validate each parameter directly.
|
|
320
|
+
*
|
|
321
|
+
* Example: { queryParameters: { page: number, limit: number } }
|
|
322
|
+
* Becomes: { query_page: number, query_limit: number }
|
|
323
|
+
*/
|
|
324
|
+
function flattenProperties(properties, required, prefix = "") {
|
|
325
|
+
const flattened = [];
|
|
326
|
+
for (const [propName, propDef] of Object.entries(properties)) {
|
|
327
|
+
const isRequired = required.includes(propName);
|
|
328
|
+
const path = prefix ? [prefix, propName] : [propName];
|
|
329
|
+
// Check if this is a nested object that should be flattened
|
|
330
|
+
if (propDef.type === "object" && propDef.properties && Object.keys(propDef.properties).length > 0) {
|
|
331
|
+
// Determine prefix for flattened names
|
|
332
|
+
let flatPrefix;
|
|
333
|
+
if (propName === "queryParameters") {
|
|
334
|
+
flatPrefix = "query";
|
|
335
|
+
}
|
|
336
|
+
else if (propName === "pathParameters") {
|
|
337
|
+
flatPrefix = "path";
|
|
338
|
+
}
|
|
339
|
+
else if (propName === "body") {
|
|
340
|
+
flatPrefix = "body";
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
flatPrefix = propName;
|
|
344
|
+
}
|
|
345
|
+
// Recursively flatten nested properties
|
|
346
|
+
const nestedRequired = propDef.required || [];
|
|
347
|
+
const nestedFlattened = flattenNestedProperties(propDef.properties, nestedRequired, flatPrefix, path, isRequired);
|
|
348
|
+
flattened.push(...nestedFlattened);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
// Non-object property, add directly
|
|
352
|
+
const flatName = prefix ? `${prefix}_${propName}` : propName;
|
|
353
|
+
flattened.push({
|
|
354
|
+
flatName,
|
|
355
|
+
originalPath: path,
|
|
356
|
+
zodType: jsonSchemaToZod(propDef),
|
|
357
|
+
isRequired
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return flattened;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Flatten nested properties with proper path tracking
|
|
365
|
+
*/
|
|
366
|
+
function flattenNestedProperties(properties, required, prefix, parentPath, parentRequired) {
|
|
367
|
+
const flattened = [];
|
|
368
|
+
for (const [propName, propDef] of Object.entries(properties)) {
|
|
369
|
+
const isRequired = parentRequired && required.includes(propName);
|
|
370
|
+
const flatName = `${prefix}_${propName}`;
|
|
371
|
+
const path = [...parentPath, propName];
|
|
372
|
+
// For deeply nested objects, still flatten but use JSON string
|
|
373
|
+
// OpenCode has limitations on deeply nested schemas
|
|
374
|
+
if (propDef.type === "object" && propDef.properties && Object.keys(propDef.properties).length > 0) {
|
|
375
|
+
// For 2+ levels deep, use JSON string with verbose description
|
|
376
|
+
const verboseDesc = generateNestedObjectDescription(propDef);
|
|
377
|
+
flattened.push({
|
|
378
|
+
flatName,
|
|
379
|
+
originalPath: path,
|
|
380
|
+
zodType: `z.string().describe(${JSON.stringify("JSON object: " + verboseDesc)})`,
|
|
381
|
+
isRequired
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
flattened.push({
|
|
386
|
+
flatName,
|
|
387
|
+
originalPath: path,
|
|
388
|
+
zodType: jsonSchemaToZod(propDef),
|
|
389
|
+
isRequired
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return flattened;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Generate code to reconstruct nested structure from flattened args.
|
|
397
|
+
* Returns null if no reconstruction is needed.
|
|
398
|
+
*/
|
|
399
|
+
function generateReconstructionCode(flattenedProps) {
|
|
400
|
+
// Check if any reconstruction is needed
|
|
401
|
+
const needsReconstruction = flattenedProps.some(p => p.originalPath.length > 1);
|
|
402
|
+
if (!needsReconstruction) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
// Group by top-level property
|
|
406
|
+
const groups = {};
|
|
407
|
+
for (const prop of flattenedProps) {
|
|
408
|
+
const topLevel = prop.originalPath[0];
|
|
409
|
+
if (!groups[topLevel]) {
|
|
410
|
+
groups[topLevel] = [];
|
|
411
|
+
}
|
|
412
|
+
groups[topLevel].push(prop);
|
|
413
|
+
}
|
|
414
|
+
// Generate reconstruction code
|
|
415
|
+
const lines = [" const mcpArgs: Record<string, any> = {}"];
|
|
416
|
+
for (const [topLevel, props] of Object.entries(groups)) {
|
|
417
|
+
if (props.every(p => p.originalPath.length === 1)) {
|
|
418
|
+
// Simple top-level property (shouldn't happen in flattened case)
|
|
419
|
+
const prop = props[0];
|
|
420
|
+
lines.push(` if (args.${prop.flatName} !== undefined) mcpArgs.${topLevel} = args.${prop.flatName}`);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
// Nested properties need reconstruction
|
|
424
|
+
lines.push(` const ${topLevel}Obj: Record<string, any> = {}`);
|
|
425
|
+
for (const prop of props) {
|
|
426
|
+
if (prop.originalPath.length > 1) {
|
|
427
|
+
const nestedKey = prop.originalPath[prop.originalPath.length - 1];
|
|
428
|
+
lines.push(` if (args.${prop.flatName} !== undefined) ${topLevel}Obj.${nestedKey} = args.${prop.flatName}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
lines.push(` if (Object.keys(${topLevel}Obj).length > 0) mcpArgs.${topLevel} = ${topLevel}Obj`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return lines.join("\n");
|
|
435
|
+
}
|
|
436
|
+
function generateToolDefinition(tool, petName) {
|
|
437
|
+
// Remove pet prefix from tool name if it already has one (e.g., asana_get_task -> get-task)
|
|
438
|
+
const toolNameWithoutPrefix = tool.name.replace(new RegExp(`^${petName}_`, 'i'), '');
|
|
439
|
+
const toolNameKebab = `${petName}-${toolNameWithoutPrefix.replace(/_/g, "-")}`;
|
|
440
|
+
const description = (tool.description || tool.title || `${tool.name} tool`)
|
|
441
|
+
.replace(/\\/g, "\\\\")
|
|
442
|
+
.replace(/`/g, "\\`")
|
|
443
|
+
.replace(/\$/g, "\\$");
|
|
444
|
+
// Get original properties
|
|
445
|
+
const properties = tool.inputSchema?.properties || {};
|
|
446
|
+
const required = tool.inputSchema?.required || [];
|
|
447
|
+
// Flatten nested properties for better OpenCode integration
|
|
448
|
+
const flattenedProps = flattenProperties(properties, required);
|
|
449
|
+
// Generate schema fields from flattened properties
|
|
450
|
+
const schemaFields = [];
|
|
451
|
+
for (const prop of flattenedProps) {
|
|
452
|
+
let zodType = prop.zodType;
|
|
453
|
+
if (!prop.isRequired && !zodType.includes(".optional()")) {
|
|
454
|
+
zodType = zodType + ".optional()";
|
|
455
|
+
}
|
|
456
|
+
schemaFields.push(` ${prop.flatName}: ${zodType}`);
|
|
457
|
+
}
|
|
458
|
+
const schemaBody = schemaFields.length > 0
|
|
459
|
+
? `z.object({\n${schemaFields.join(",\n")}\n })`
|
|
460
|
+
: "z.object({})";
|
|
461
|
+
// Generate args reconstruction code if needed
|
|
462
|
+
const reconstructionCode = generateReconstructionCode(flattenedProps);
|
|
463
|
+
if (reconstructionCode) {
|
|
464
|
+
return ` {
|
|
465
|
+
name: "${toolNameKebab}",
|
|
466
|
+
description: \`${description}\`,
|
|
467
|
+
schema: ${schemaBody},
|
|
468
|
+
async execute(args) {
|
|
469
|
+
// Reconstruct nested structure from flattened args
|
|
470
|
+
${reconstructionCode}
|
|
471
|
+
return callMCPTool("${tool.name}", mcpArgs)
|
|
472
|
+
}
|
|
473
|
+
}`;
|
|
474
|
+
}
|
|
475
|
+
return ` {
|
|
476
|
+
name: "${toolNameKebab}",
|
|
477
|
+
description: \`${description}\`,
|
|
478
|
+
schema: ${schemaBody},
|
|
479
|
+
async execute(args) {
|
|
480
|
+
return callMCPTool("${tool.name}", args)
|
|
481
|
+
}
|
|
482
|
+
}`;
|
|
483
|
+
}
|
|
484
|
+
function generateMCPToolsFile(options) {
|
|
485
|
+
const { tools, petName, serverIdentifier, transport, envVarName, dockerImage, authHeader } = options;
|
|
486
|
+
const toolDefinitions = tools.map(tool => generateToolDefinition(tool, petName)).join(",\n\n");
|
|
487
|
+
switch (transport) {
|
|
488
|
+
case "sse-remote":
|
|
489
|
+
return generateOAuthMCPFile(toolDefinitions, petName, serverIdentifier, tools.length);
|
|
490
|
+
case "docker":
|
|
491
|
+
return generateDockerMCPFile(toolDefinitions, petName, dockerImage || serverIdentifier, envVarName, tools.length);
|
|
492
|
+
case "http":
|
|
493
|
+
return generateHttpMCPFile(toolDefinitions, petName, serverIdentifier, envVarName, authHeader || "Authorization", tools.length);
|
|
494
|
+
default:
|
|
495
|
+
return generateStdioMCPFile(toolDefinitions, petName, serverIdentifier, envVarName, tools.length);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function generateOAuthMCPFile(toolDefinitions, petName, serverUrl, toolCount) {
|
|
499
|
+
return `/**
|
|
500
|
+
* Auto-generated MCP tools from ${serverUrl}
|
|
501
|
+
* Generated by: pets generate-mcp
|
|
502
|
+
* Transport: SSE Remote (OAuth via mcp-remote)
|
|
503
|
+
*
|
|
504
|
+
* DO NOT EDIT MANUALLY - Regenerate with: pets generate-mcp
|
|
505
|
+
*/
|
|
506
|
+
|
|
507
|
+
import { z, type ToolDefinition } from "openpets-sdk"
|
|
508
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
509
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
510
|
+
|
|
511
|
+
// MCP client state
|
|
512
|
+
let mcpClient: Client | null = null
|
|
513
|
+
let lastConnectionTime: number = 0
|
|
514
|
+
let connectionPromise: Promise<Client> | null = null
|
|
515
|
+
|
|
516
|
+
// Configuration
|
|
517
|
+
const CONNECTION_TIMEOUT_MS = 60000 // 60 seconds (OAuth may take longer)
|
|
518
|
+
const CONNECTION_MAX_AGE_MS = 10 * 60 * 1000 // 10 minutes
|
|
519
|
+
const MAX_RETRIES = 2
|
|
520
|
+
const RETRY_DELAY_MS = 1000
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Sleep helper for retry delays
|
|
524
|
+
*/
|
|
525
|
+
function sleep(ms: number): Promise<void> {
|
|
526
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Check if current connection is stale
|
|
531
|
+
*/
|
|
532
|
+
function isConnectionStale(): boolean {
|
|
533
|
+
return Date.now() - lastConnectionTime > CONNECTION_MAX_AGE_MS
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Reset the client connection
|
|
538
|
+
*/
|
|
539
|
+
async function resetConnection(): Promise<void> {
|
|
540
|
+
if (mcpClient) {
|
|
541
|
+
try {
|
|
542
|
+
await mcpClient.close()
|
|
543
|
+
} catch {
|
|
544
|
+
// Ignore close errors
|
|
545
|
+
}
|
|
546
|
+
mcpClient = null
|
|
547
|
+
}
|
|
548
|
+
connectionPromise = null
|
|
549
|
+
lastConnectionTime = 0
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Get or initialize the MCP client connection via mcp-remote (OAuth)
|
|
554
|
+
* Note: First connection will open a browser for OAuth authentication
|
|
555
|
+
*/
|
|
556
|
+
async function getMCPClient(): Promise<Client> {
|
|
557
|
+
// If we have a valid, non-stale connection, reuse it
|
|
558
|
+
if (mcpClient && !isConnectionStale()) {
|
|
559
|
+
return mcpClient
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// If connection is stale, reset it
|
|
563
|
+
if (mcpClient && isConnectionStale()) {
|
|
564
|
+
await resetConnection()
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// If a connection is already in progress, wait for it
|
|
568
|
+
if (connectionPromise) {
|
|
569
|
+
return connectionPromise
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Create new connection
|
|
573
|
+
connectionPromise = (async () => {
|
|
574
|
+
const client = new Client({
|
|
575
|
+
name: "openpets-${petName}",
|
|
576
|
+
version: "1.0.0",
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
const transport = new StdioClientTransport({
|
|
580
|
+
command: "bunx",
|
|
581
|
+
args: ["mcp-remote", "${serverUrl}"],
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// Connect with timeout
|
|
585
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
586
|
+
setTimeout(() => reject(new Error("OAuth connection timeout")), CONNECTION_TIMEOUT_MS)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
await Promise.race([client.connect(transport), timeoutPromise])
|
|
590
|
+
|
|
591
|
+
mcpClient = client
|
|
592
|
+
lastConnectionTime = Date.now()
|
|
593
|
+
return client
|
|
594
|
+
})()
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
return await connectionPromise
|
|
598
|
+
} catch (error) {
|
|
599
|
+
connectionPromise = null
|
|
600
|
+
throw error
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Call an MCP tool with retry logic and better error handling
|
|
606
|
+
*/
|
|
607
|
+
async function callMCPTool(toolName: string, args: Record<string, any>): Promise<string> {
|
|
608
|
+
let lastError: Error | null = null
|
|
609
|
+
|
|
610
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
611
|
+
try {
|
|
612
|
+
const client = await getMCPClient()
|
|
613
|
+
|
|
614
|
+
// Call with timeout
|
|
615
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
616
|
+
setTimeout(() => reject(new Error("Tool call timeout")), CONNECTION_TIMEOUT_MS)
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
const result = await Promise.race([
|
|
620
|
+
client.callTool({ name: toolName, arguments: args }),
|
|
621
|
+
timeoutPromise
|
|
622
|
+
])
|
|
623
|
+
|
|
624
|
+
return JSON.stringify(result, null, 2)
|
|
625
|
+
} catch (error: any) {
|
|
626
|
+
lastError = error
|
|
627
|
+
|
|
628
|
+
// On connection-related errors, reset and retry
|
|
629
|
+
if (error.message?.includes("timeout") ||
|
|
630
|
+
error.message?.includes("connection") ||
|
|
631
|
+
error.message?.includes("closed")) {
|
|
632
|
+
await resetConnection()
|
|
633
|
+
if (attempt < MAX_RETRIES) {
|
|
634
|
+
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
|
635
|
+
continue
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// For other errors, don't retry
|
|
640
|
+
break
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return JSON.stringify({
|
|
645
|
+
success: false,
|
|
646
|
+
error: lastError?.message || "Unknown error",
|
|
647
|
+
tool: toolName
|
|
648
|
+
}, null, 2)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Close the MCP client connection
|
|
653
|
+
*/
|
|
654
|
+
export async function closeMCPClient(): Promise<void> {
|
|
655
|
+
await resetConnection()
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Check if MCP client is connected and healthy
|
|
660
|
+
*/
|
|
661
|
+
export function isMCPClientConnected(): boolean {
|
|
662
|
+
return mcpClient !== null && !isConnectionStale()
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Auto-generated MCP tools
|
|
667
|
+
* Total: ${toolCount} tools
|
|
668
|
+
*/
|
|
669
|
+
export const mcpTools: ToolDefinition[] = [
|
|
670
|
+
${toolDefinitions}
|
|
671
|
+
]
|
|
672
|
+
|
|
673
|
+
export default mcpTools
|
|
674
|
+
`;
|
|
675
|
+
}
|
|
676
|
+
function generateDockerMCPFile(toolDefinitions, petName, dockerImage, envVarName, toolCount) {
|
|
677
|
+
return `/**
|
|
678
|
+
* Auto-generated MCP tools from ${dockerImage}
|
|
679
|
+
* Generated by: pets generate-mcp
|
|
680
|
+
* Transport: Docker (container with env var authentication)
|
|
681
|
+
*
|
|
682
|
+
* DO NOT EDIT MANUALLY - Regenerate with: pets generate-mcp
|
|
683
|
+
*/
|
|
684
|
+
|
|
685
|
+
import { z, type ToolDefinition } from "openpets-sdk"
|
|
686
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
687
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
688
|
+
|
|
689
|
+
// MCP client state
|
|
690
|
+
let mcpClient: Client | null = null
|
|
691
|
+
let lastConnectionTime: number = 0
|
|
692
|
+
let connectionPromise: Promise<Client> | null = null
|
|
693
|
+
|
|
694
|
+
// Configuration
|
|
695
|
+
const CONNECTION_TIMEOUT_MS = 60000 // 60 seconds (Docker startup may take time)
|
|
696
|
+
const CONNECTION_MAX_AGE_MS = 10 * 60 * 1000 // 10 minutes
|
|
697
|
+
const MAX_RETRIES = 2
|
|
698
|
+
const RETRY_DELAY_MS = 2000 // Longer delay for Docker restarts
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Sleep helper for retry delays
|
|
702
|
+
*/
|
|
703
|
+
function sleep(ms: number): Promise<void> {
|
|
704
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Check if current connection is stale
|
|
709
|
+
*/
|
|
710
|
+
function isConnectionStale(): boolean {
|
|
711
|
+
return Date.now() - lastConnectionTime > CONNECTION_MAX_AGE_MS
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Reset the client connection
|
|
716
|
+
*/
|
|
717
|
+
async function resetConnection(): Promise<void> {
|
|
718
|
+
if (mcpClient) {
|
|
719
|
+
try {
|
|
720
|
+
await mcpClient.close()
|
|
721
|
+
} catch {
|
|
722
|
+
// Ignore close errors
|
|
723
|
+
}
|
|
724
|
+
mcpClient = null
|
|
725
|
+
}
|
|
726
|
+
connectionPromise = null
|
|
727
|
+
lastConnectionTime = 0
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Create a new Docker MCP client connection with timeout
|
|
732
|
+
*/
|
|
733
|
+
async function createConnection(accessToken: string): Promise<Client> {
|
|
734
|
+
const client = new Client({
|
|
735
|
+
name: "openpets-${petName}",
|
|
736
|
+
version: "1.0.0",
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
const transport = new StdioClientTransport({
|
|
740
|
+
command: "docker",
|
|
741
|
+
args: [
|
|
742
|
+
"run", "-i", "--rm",
|
|
743
|
+
"-e", "${envVarName}",
|
|
744
|
+
"${dockerImage}",
|
|
745
|
+
],
|
|
746
|
+
env: {
|
|
747
|
+
...process.env,
|
|
748
|
+
${envVarName}: accessToken,
|
|
749
|
+
},
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
// Connect with timeout
|
|
753
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
754
|
+
setTimeout(() => reject(new Error("Docker connection timeout")), CONNECTION_TIMEOUT_MS)
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
await Promise.race([client.connect(transport), timeoutPromise])
|
|
758
|
+
return client
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Get or initialize the MCP client connection via Docker
|
|
763
|
+
* Requires Docker to be installed and running
|
|
764
|
+
* Uses connection pooling and retry logic for resilience
|
|
765
|
+
*/
|
|
766
|
+
async function getMCPClient(accessToken: string): Promise<Client> {
|
|
767
|
+
// If we have a valid, non-stale connection, reuse it
|
|
768
|
+
if (mcpClient && !isConnectionStale()) {
|
|
769
|
+
return mcpClient
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// If connection is stale, reset it
|
|
773
|
+
if (mcpClient && isConnectionStale()) {
|
|
774
|
+
await resetConnection()
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// If a connection is already in progress, wait for it
|
|
778
|
+
if (connectionPromise) {
|
|
779
|
+
return connectionPromise
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Create new connection with retry logic
|
|
783
|
+
connectionPromise = (async () => {
|
|
784
|
+
let lastError: Error | null = null
|
|
785
|
+
|
|
786
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
787
|
+
try {
|
|
788
|
+
if (attempt > 0) {
|
|
789
|
+
await sleep(RETRY_DELAY_MS * attempt)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const client = await createConnection(accessToken)
|
|
793
|
+
mcpClient = client
|
|
794
|
+
lastConnectionTime = Date.now()
|
|
795
|
+
return client
|
|
796
|
+
} catch (error: any) {
|
|
797
|
+
lastError = error
|
|
798
|
+
if (attempt < MAX_RETRIES) {
|
|
799
|
+
console.warn(\`Docker MCP connection attempt \${attempt + 1} failed: \${error.message}. Retrying...\`)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
connectionPromise = null
|
|
805
|
+
throw lastError || new Error("Failed to connect to Docker MCP server")
|
|
806
|
+
})()
|
|
807
|
+
|
|
808
|
+
return connectionPromise
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Call an MCP tool with retry logic and better error handling
|
|
813
|
+
*/
|
|
814
|
+
async function callMCPTool(toolName: string, args: Record<string, any>): Promise<string> {
|
|
815
|
+
const accessToken = process.env.${envVarName}
|
|
816
|
+
if (!accessToken) {
|
|
817
|
+
return JSON.stringify({
|
|
818
|
+
success: false,
|
|
819
|
+
error: "Missing ${envVarName} environment variable",
|
|
820
|
+
tool: toolName
|
|
821
|
+
}, null, 2)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let lastError: Error | null = null
|
|
825
|
+
|
|
826
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
827
|
+
try {
|
|
828
|
+
const client = await getMCPClient(accessToken)
|
|
829
|
+
|
|
830
|
+
// Call with timeout
|
|
831
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
832
|
+
setTimeout(() => reject(new Error("Tool call timeout")), CONNECTION_TIMEOUT_MS)
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
const result = await Promise.race([
|
|
836
|
+
client.callTool({ name: toolName, arguments: args }),
|
|
837
|
+
timeoutPromise
|
|
838
|
+
])
|
|
839
|
+
|
|
840
|
+
return JSON.stringify(result, null, 2)
|
|
841
|
+
} catch (error: any) {
|
|
842
|
+
lastError = error
|
|
843
|
+
|
|
844
|
+
// On connection-related errors, reset and retry
|
|
845
|
+
if (error.message?.includes("timeout") ||
|
|
846
|
+
error.message?.includes("connection") ||
|
|
847
|
+
error.message?.includes("Docker") ||
|
|
848
|
+
error.message?.includes("closed") ||
|
|
849
|
+
error.message?.includes("ENOENT")) {
|
|
850
|
+
await resetConnection()
|
|
851
|
+
if (attempt < MAX_RETRIES) {
|
|
852
|
+
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
|
853
|
+
continue
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// For other errors, don't retry
|
|
858
|
+
break
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return JSON.stringify({
|
|
863
|
+
success: false,
|
|
864
|
+
error: lastError?.message || "Unknown error",
|
|
865
|
+
tool: toolName
|
|
866
|
+
}, null, 2)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Close the MCP client connection
|
|
871
|
+
*/
|
|
872
|
+
export async function closeMCPClient(): Promise<void> {
|
|
873
|
+
await resetConnection()
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Check if MCP client is connected and healthy
|
|
878
|
+
*/
|
|
879
|
+
export function isMCPClientConnected(): boolean {
|
|
880
|
+
return mcpClient !== null && !isConnectionStale()
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Auto-generated MCP tools
|
|
885
|
+
* Total: ${toolCount} tools
|
|
886
|
+
*/
|
|
887
|
+
export const mcpTools: ToolDefinition[] = [
|
|
888
|
+
${toolDefinitions}
|
|
889
|
+
]
|
|
890
|
+
|
|
891
|
+
export default mcpTools
|
|
892
|
+
`;
|
|
893
|
+
}
|
|
894
|
+
function generateHttpMCPFile(toolDefinitions, petName, serverUrl, envVarName, authHeader, toolCount) {
|
|
895
|
+
return `/**
|
|
896
|
+
* Auto-generated MCP tools from ${serverUrl}
|
|
897
|
+
* Generated by: pets generate-mcp
|
|
898
|
+
* Transport: HTTP (with Bearer token authentication)
|
|
899
|
+
*
|
|
900
|
+
* DO NOT EDIT MANUALLY - Regenerate with: pets generate-mcp
|
|
901
|
+
*/
|
|
902
|
+
|
|
903
|
+
import { z, type ToolDefinition } from "openpets-sdk"
|
|
904
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
905
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
906
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
907
|
+
|
|
908
|
+
// MCP client state
|
|
909
|
+
let mcpClient: Client | null = null
|
|
910
|
+
let lastConnectionTime: number = 0
|
|
911
|
+
let connectionPromise: Promise<Client> | null = null
|
|
912
|
+
|
|
913
|
+
// Configuration
|
|
914
|
+
const CONNECTION_TIMEOUT_MS = 30000 // 30 seconds
|
|
915
|
+
const CONNECTION_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes - reconnect after this
|
|
916
|
+
const MAX_RETRIES = 2
|
|
917
|
+
const RETRY_DELAY_MS = 1000
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Sleep helper for retry delays
|
|
921
|
+
*/
|
|
922
|
+
function sleep(ms: number): Promise<void> {
|
|
923
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Check if current connection is stale
|
|
928
|
+
*/
|
|
929
|
+
function isConnectionStale(): boolean {
|
|
930
|
+
return Date.now() - lastConnectionTime > CONNECTION_MAX_AGE_MS
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Reset the client connection
|
|
935
|
+
*/
|
|
936
|
+
async function resetConnection(): Promise<void> {
|
|
937
|
+
if (mcpClient) {
|
|
938
|
+
try {
|
|
939
|
+
await mcpClient.close()
|
|
940
|
+
} catch {
|
|
941
|
+
// Ignore close errors
|
|
942
|
+
}
|
|
943
|
+
mcpClient = null
|
|
944
|
+
}
|
|
945
|
+
connectionPromise = null
|
|
946
|
+
lastConnectionTime = 0
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Create a new MCP client connection with timeout
|
|
951
|
+
*/
|
|
952
|
+
async function createConnection(accessToken: string): Promise<Client> {
|
|
953
|
+
const client = new Client({
|
|
954
|
+
name: "openpets-${petName}",
|
|
955
|
+
version: "1.0.0",
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
const headers = {
|
|
959
|
+
"${authHeader}": \`Bearer \${accessToken}\`,
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Create connection with timeout
|
|
963
|
+
const connectWithTimeout = async (transport: any) => {
|
|
964
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
965
|
+
setTimeout(() => reject(new Error("Connection timeout")), CONNECTION_TIMEOUT_MS)
|
|
966
|
+
})
|
|
967
|
+
await Promise.race([client.connect(transport), timeoutPromise])
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Try StreamableHTTP first, fall back to SSE
|
|
971
|
+
try {
|
|
972
|
+
const transport = new StreamableHTTPClientTransport(new URL("${serverUrl}"), {
|
|
973
|
+
requestInit: { headers },
|
|
974
|
+
})
|
|
975
|
+
await connectWithTimeout(transport)
|
|
976
|
+
} catch (httpError: any) {
|
|
977
|
+
const sseTransport = new SSEClientTransport(new URL("${serverUrl}"), {
|
|
978
|
+
requestInit: { headers },
|
|
979
|
+
})
|
|
980
|
+
await connectWithTimeout(sseTransport)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return client
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Get or initialize the MCP client connection via HTTP
|
|
988
|
+
* Uses Bearer token authentication with connection pooling and retry logic
|
|
989
|
+
*/
|
|
990
|
+
async function getMCPClient(accessToken: string): Promise<Client> {
|
|
991
|
+
// If we have a valid, non-stale connection, reuse it
|
|
992
|
+
if (mcpClient && !isConnectionStale()) {
|
|
993
|
+
return mcpClient
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// If connection is stale, reset it
|
|
997
|
+
if (mcpClient && isConnectionStale()) {
|
|
998
|
+
await resetConnection()
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// If a connection is already in progress, wait for it
|
|
1002
|
+
if (connectionPromise) {
|
|
1003
|
+
return connectionPromise
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Create new connection with retry logic
|
|
1007
|
+
connectionPromise = (async () => {
|
|
1008
|
+
let lastError: Error | null = null
|
|
1009
|
+
|
|
1010
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1011
|
+
try {
|
|
1012
|
+
if (attempt > 0) {
|
|
1013
|
+
await sleep(RETRY_DELAY_MS * attempt)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const client = await createConnection(accessToken)
|
|
1017
|
+
mcpClient = client
|
|
1018
|
+
lastConnectionTime = Date.now()
|
|
1019
|
+
return client
|
|
1020
|
+
} catch (error: any) {
|
|
1021
|
+
lastError = error
|
|
1022
|
+
if (attempt < MAX_RETRIES) {
|
|
1023
|
+
console.warn(\`MCP connection attempt \${attempt + 1} failed: \${error.message}. Retrying...\`)
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
connectionPromise = null
|
|
1029
|
+
throw lastError || new Error("Failed to connect to MCP server")
|
|
1030
|
+
})()
|
|
1031
|
+
|
|
1032
|
+
return connectionPromise
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Call an MCP tool with retry logic and better error handling
|
|
1037
|
+
*/
|
|
1038
|
+
async function callMCPTool(toolName: string, args: Record<string, any>): Promise<string> {
|
|
1039
|
+
const accessToken = process.env.${envVarName}
|
|
1040
|
+
if (!accessToken) {
|
|
1041
|
+
return JSON.stringify({
|
|
1042
|
+
success: false,
|
|
1043
|
+
error: "Missing ${envVarName} environment variable",
|
|
1044
|
+
tool: toolName
|
|
1045
|
+
}, null, 2)
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
let lastError: Error | null = null
|
|
1049
|
+
|
|
1050
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1051
|
+
try {
|
|
1052
|
+
const client = await getMCPClient(accessToken)
|
|
1053
|
+
|
|
1054
|
+
// Call with timeout
|
|
1055
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
1056
|
+
setTimeout(() => reject(new Error("Tool call timeout")), CONNECTION_TIMEOUT_MS)
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
const result = await Promise.race([
|
|
1060
|
+
client.callTool({ name: toolName, arguments: args }),
|
|
1061
|
+
timeoutPromise
|
|
1062
|
+
])
|
|
1063
|
+
|
|
1064
|
+
return JSON.stringify(result, null, 2)
|
|
1065
|
+
} catch (error: any) {
|
|
1066
|
+
lastError = error
|
|
1067
|
+
|
|
1068
|
+
// On connection-related errors, reset and retry
|
|
1069
|
+
if (error.message?.includes("timeout") ||
|
|
1070
|
+
error.message?.includes("connection") ||
|
|
1071
|
+
error.message?.includes("ECONNREFUSED") ||
|
|
1072
|
+
error.message?.includes("socket")) {
|
|
1073
|
+
await resetConnection()
|
|
1074
|
+
if (attempt < MAX_RETRIES) {
|
|
1075
|
+
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
|
1076
|
+
continue
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// For other errors, don't retry
|
|
1081
|
+
break
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return JSON.stringify({
|
|
1086
|
+
success: false,
|
|
1087
|
+
error: lastError?.message || "Unknown error",
|
|
1088
|
+
tool: toolName
|
|
1089
|
+
}, null, 2)
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Close the MCP client connection
|
|
1094
|
+
*/
|
|
1095
|
+
export async function closeMCPClient(): Promise<void> {
|
|
1096
|
+
await resetConnection()
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Check if MCP client is connected and healthy
|
|
1101
|
+
*/
|
|
1102
|
+
export function isMCPClientConnected(): boolean {
|
|
1103
|
+
return mcpClient !== null && !isConnectionStale()
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Auto-generated MCP tools
|
|
1108
|
+
* Total: ${toolCount} tools
|
|
1109
|
+
*/
|
|
1110
|
+
export const mcpTools: ToolDefinition[] = [
|
|
1111
|
+
${toolDefinitions}
|
|
1112
|
+
]
|
|
1113
|
+
|
|
1114
|
+
export default mcpTools
|
|
1115
|
+
`;
|
|
1116
|
+
}
|
|
1117
|
+
function generateStdioMCPFile(toolDefinitions, petName, npmPackage, envVarName, toolCount) {
|
|
1118
|
+
return `/**
|
|
1119
|
+
* Auto-generated MCP tools from ${npmPackage}
|
|
1120
|
+
* Generated by: pets generate-mcp
|
|
1121
|
+
* Transport: Stdio (direct npm package)
|
|
1122
|
+
*
|
|
1123
|
+
* DO NOT EDIT MANUALLY - Regenerate with: pets generate-mcp
|
|
1124
|
+
*/
|
|
1125
|
+
|
|
1126
|
+
import { z, type ToolDefinition } from "openpets-sdk"
|
|
1127
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
1128
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
1129
|
+
|
|
1130
|
+
// MCP client state
|
|
1131
|
+
let mcpClient: Client | null = null
|
|
1132
|
+
let lastConnectionTime: number = 0
|
|
1133
|
+
let connectionPromise: Promise<Client> | null = null
|
|
1134
|
+
|
|
1135
|
+
// Configuration
|
|
1136
|
+
const CONNECTION_TIMEOUT_MS = 30000 // 30 seconds
|
|
1137
|
+
const CONNECTION_MAX_AGE_MS = 10 * 60 * 1000 // 10 minutes
|
|
1138
|
+
const MAX_RETRIES = 2
|
|
1139
|
+
const RETRY_DELAY_MS = 1000
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Sleep helper for retry delays
|
|
1143
|
+
*/
|
|
1144
|
+
function sleep(ms: number): Promise<void> {
|
|
1145
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Check if current connection is stale
|
|
1150
|
+
*/
|
|
1151
|
+
function isConnectionStale(): boolean {
|
|
1152
|
+
return Date.now() - lastConnectionTime > CONNECTION_MAX_AGE_MS
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Reset the client connection
|
|
1157
|
+
*/
|
|
1158
|
+
async function resetConnection(): Promise<void> {
|
|
1159
|
+
if (mcpClient) {
|
|
1160
|
+
try {
|
|
1161
|
+
await mcpClient.close()
|
|
1162
|
+
} catch {
|
|
1163
|
+
// Ignore close errors
|
|
1164
|
+
}
|
|
1165
|
+
mcpClient = null
|
|
1166
|
+
}
|
|
1167
|
+
connectionPromise = null
|
|
1168
|
+
lastConnectionTime = 0
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Create a new stdio MCP client connection with timeout
|
|
1173
|
+
*/
|
|
1174
|
+
async function createConnection(accessToken: string): Promise<Client> {
|
|
1175
|
+
const client = new Client({
|
|
1176
|
+
name: "openpets-${petName}",
|
|
1177
|
+
version: "1.0.0",
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
const transport = new StdioClientTransport({
|
|
1181
|
+
command: "bunx",
|
|
1182
|
+
args: ["${npmPackage}", \`--access-token=\${accessToken}\`],
|
|
1183
|
+
})
|
|
1184
|
+
|
|
1185
|
+
// Connect with timeout
|
|
1186
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
1187
|
+
setTimeout(() => reject(new Error("Connection timeout")), CONNECTION_TIMEOUT_MS)
|
|
1188
|
+
})
|
|
1189
|
+
|
|
1190
|
+
await Promise.race([client.connect(transport), timeoutPromise])
|
|
1191
|
+
return client
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Get or initialize the MCP client connection
|
|
1196
|
+
* Uses connection pooling and retry logic for resilience
|
|
1197
|
+
*/
|
|
1198
|
+
async function getMCPClient(accessToken: string): Promise<Client> {
|
|
1199
|
+
// If we have a valid, non-stale connection, reuse it
|
|
1200
|
+
if (mcpClient && !isConnectionStale()) {
|
|
1201
|
+
return mcpClient
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// If connection is stale, reset it
|
|
1205
|
+
if (mcpClient && isConnectionStale()) {
|
|
1206
|
+
await resetConnection()
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// If a connection is already in progress, wait for it
|
|
1210
|
+
if (connectionPromise) {
|
|
1211
|
+
return connectionPromise
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Create new connection with retry logic
|
|
1215
|
+
connectionPromise = (async () => {
|
|
1216
|
+
let lastError: Error | null = null
|
|
1217
|
+
|
|
1218
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1219
|
+
try {
|
|
1220
|
+
if (attempt > 0) {
|
|
1221
|
+
await sleep(RETRY_DELAY_MS * attempt)
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const client = await createConnection(accessToken)
|
|
1225
|
+
mcpClient = client
|
|
1226
|
+
lastConnectionTime = Date.now()
|
|
1227
|
+
return client
|
|
1228
|
+
} catch (error: any) {
|
|
1229
|
+
lastError = error
|
|
1230
|
+
if (attempt < MAX_RETRIES) {
|
|
1231
|
+
console.warn(\`MCP connection attempt \${attempt + 1} failed: \${error.message}. Retrying...\`)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
connectionPromise = null
|
|
1237
|
+
throw lastError || new Error("Failed to connect to MCP server")
|
|
1238
|
+
})()
|
|
1239
|
+
|
|
1240
|
+
return connectionPromise
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Call an MCP tool with retry logic and better error handling
|
|
1245
|
+
*/
|
|
1246
|
+
async function callMCPTool(toolName: string, args: Record<string, any>): Promise<string> {
|
|
1247
|
+
const accessToken = process.env.${envVarName}
|
|
1248
|
+
if (!accessToken) {
|
|
1249
|
+
return JSON.stringify({
|
|
1250
|
+
success: false,
|
|
1251
|
+
error: "Missing ${envVarName} environment variable",
|
|
1252
|
+
tool: toolName
|
|
1253
|
+
}, null, 2)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
let lastError: Error | null = null
|
|
1257
|
+
|
|
1258
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1259
|
+
try {
|
|
1260
|
+
const client = await getMCPClient(accessToken)
|
|
1261
|
+
|
|
1262
|
+
// Call with timeout
|
|
1263
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
1264
|
+
setTimeout(() => reject(new Error("Tool call timeout")), CONNECTION_TIMEOUT_MS)
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
const result = await Promise.race([
|
|
1268
|
+
client.callTool({ name: toolName, arguments: args }),
|
|
1269
|
+
timeoutPromise
|
|
1270
|
+
])
|
|
1271
|
+
|
|
1272
|
+
return JSON.stringify(result, null, 2)
|
|
1273
|
+
} catch (error: any) {
|
|
1274
|
+
lastError = error
|
|
1275
|
+
|
|
1276
|
+
// On connection-related errors, reset and retry
|
|
1277
|
+
if (error.message?.includes("timeout") ||
|
|
1278
|
+
error.message?.includes("connection") ||
|
|
1279
|
+
error.message?.includes("closed") ||
|
|
1280
|
+
error.message?.includes("ENOENT")) {
|
|
1281
|
+
await resetConnection()
|
|
1282
|
+
if (attempt < MAX_RETRIES) {
|
|
1283
|
+
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
|
1284
|
+
continue
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// For other errors, don't retry
|
|
1289
|
+
break
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
return JSON.stringify({
|
|
1294
|
+
success: false,
|
|
1295
|
+
error: lastError?.message || "Unknown error",
|
|
1296
|
+
tool: toolName
|
|
1297
|
+
}, null, 2)
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Close the MCP client connection
|
|
1302
|
+
*/
|
|
1303
|
+
export async function closeMCPClient(): Promise<void> {
|
|
1304
|
+
await resetConnection()
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Check if MCP client is connected and healthy
|
|
1309
|
+
*/
|
|
1310
|
+
export function isMCPClientConnected(): boolean {
|
|
1311
|
+
return mcpClient !== null && !isConnectionStale()
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Auto-generated MCP tools
|
|
1316
|
+
* Total: ${toolCount} tools
|
|
1317
|
+
*/
|
|
1318
|
+
export const mcpTools: ToolDefinition[] = [
|
|
1319
|
+
${toolDefinitions}
|
|
1320
|
+
]
|
|
1321
|
+
|
|
1322
|
+
export default mcpTools
|
|
1323
|
+
`;
|
|
1324
|
+
}
|
|
1325
|
+
export async function generateMCPTools(options = {}) {
|
|
1326
|
+
const cwd = process.cwd();
|
|
1327
|
+
const outputFile = options.outputFile || "mcp.ts";
|
|
1328
|
+
// Load MCP config from package.json
|
|
1329
|
+
const mcpInfo = loadMCPConfigFromPackageJson(cwd);
|
|
1330
|
+
if (!mcpInfo) {
|
|
1331
|
+
return {
|
|
1332
|
+
success: false,
|
|
1333
|
+
message: "No mcpServer configuration found in package.json. Add mcpServer.npmPackage to your package.json."
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
const { config, petName, authToken } = mcpInfo;
|
|
1337
|
+
const npmPackage = config.npmPackage || `@${petName}/mcp-server`;
|
|
1338
|
+
const transport = config.transport || "stdio";
|
|
1339
|
+
const isRemoteOAuth = transport === "sse-remote";
|
|
1340
|
+
const envVarName = config.authEnvVar || `${petName.toUpperCase().replace(/-/g, "_")}_ACCESS_TOKEN`;
|
|
1341
|
+
// For non-OAuth servers, require auth token
|
|
1342
|
+
if (!isRemoteOAuth && !authToken) {
|
|
1343
|
+
return {
|
|
1344
|
+
success: false,
|
|
1345
|
+
message: `Missing ${envVarName} environment variable. Set it in .env to connect to the MCP server.`
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
if (options.verbose) {
|
|
1349
|
+
console.log(`Transport: ${transport}`);
|
|
1350
|
+
if (isRemoteOAuth) {
|
|
1351
|
+
console.log(`Connecting to remote MCP server via OAuth: ${config.url}`);
|
|
1352
|
+
}
|
|
1353
|
+
else if (transport === "docker") {
|
|
1354
|
+
console.log(`Connecting to Docker MCP server: ${config.dockerImage}`);
|
|
1355
|
+
}
|
|
1356
|
+
else if (transport === "http") {
|
|
1357
|
+
console.log(`Connecting to HTTP MCP server: ${config.url}`);
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
console.log(`Connecting to MCP server: ${npmPackage}`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
try {
|
|
1364
|
+
// Connect to MCP server
|
|
1365
|
+
const { client, tools } = await connectToMCPServer(config, authToken);
|
|
1366
|
+
if (options.verbose) {
|
|
1367
|
+
console.log(`Found ${tools.length} tools`);
|
|
1368
|
+
}
|
|
1369
|
+
// Dump raw schemas if requested
|
|
1370
|
+
if (options.dumpSchemas) {
|
|
1371
|
+
const schemasFile = resolve(cwd, "mcp-schemas.json");
|
|
1372
|
+
const schemasData = {
|
|
1373
|
+
generatedAt: new Date().toISOString(),
|
|
1374
|
+
serverUrl: config.url || npmPackage,
|
|
1375
|
+
transport,
|
|
1376
|
+
toolCount: tools.length,
|
|
1377
|
+
tools: tools.map(t => ({
|
|
1378
|
+
name: t.name,
|
|
1379
|
+
description: t.description,
|
|
1380
|
+
inputSchema: t.inputSchema,
|
|
1381
|
+
outputSchema: t.outputSchema
|
|
1382
|
+
}))
|
|
1383
|
+
};
|
|
1384
|
+
writeFileSync(schemasFile, JSON.stringify(schemasData, null, 2));
|
|
1385
|
+
console.log(`Dumped raw schemas to: ${schemasFile}`);
|
|
1386
|
+
}
|
|
1387
|
+
// Determine server identifier based on transport
|
|
1388
|
+
let serverIdentifier;
|
|
1389
|
+
switch (transport) {
|
|
1390
|
+
case "sse-remote":
|
|
1391
|
+
case "http":
|
|
1392
|
+
serverIdentifier = config.url;
|
|
1393
|
+
break;
|
|
1394
|
+
case "docker":
|
|
1395
|
+
serverIdentifier = config.dockerImage || `ghcr.io/${petName}/${petName}-mcp-server`;
|
|
1396
|
+
break;
|
|
1397
|
+
default:
|
|
1398
|
+
serverIdentifier = npmPackage;
|
|
1399
|
+
}
|
|
1400
|
+
// Generate the mcp.ts file content
|
|
1401
|
+
const content = generateMCPToolsFile({
|
|
1402
|
+
tools,
|
|
1403
|
+
petName,
|
|
1404
|
+
serverIdentifier,
|
|
1405
|
+
transport,
|
|
1406
|
+
envVarName,
|
|
1407
|
+
dockerImage: config.dockerImage,
|
|
1408
|
+
authHeader: config.authHeader,
|
|
1409
|
+
});
|
|
1410
|
+
if (options.dryRun) {
|
|
1411
|
+
console.log("\n--- Generated mcp.ts ---");
|
|
1412
|
+
console.log(content);
|
|
1413
|
+
console.log("--- End ---\n");
|
|
1414
|
+
}
|
|
1415
|
+
else {
|
|
1416
|
+
const outputPath = resolve(cwd, outputFile);
|
|
1417
|
+
writeFileSync(outputPath, content);
|
|
1418
|
+
}
|
|
1419
|
+
// Close client
|
|
1420
|
+
await client.close();
|
|
1421
|
+
return {
|
|
1422
|
+
success: true,
|
|
1423
|
+
message: options.dryRun
|
|
1424
|
+
? `Would generate ${tools.length} tools to ${outputFile}`
|
|
1425
|
+
: `Generated ${tools.length} tools to ${outputFile}`,
|
|
1426
|
+
toolCount: tools.length,
|
|
1427
|
+
outputPath: resolve(cwd, outputFile),
|
|
1428
|
+
tools,
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
catch (error) {
|
|
1432
|
+
return {
|
|
1433
|
+
success: false,
|
|
1434
|
+
message: `Failed to connect to MCP server: ${error.message}`
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
//# sourceMappingURL=mcp-generator.js.map
|