openpets 1.0.11 → 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.
Files changed (72) hide show
  1. package/dist/data/api.json +3758 -7222
  2. package/dist/src/core/build-pet.d.ts.map +1 -1
  3. package/dist/src/core/build-pet.js +7 -0
  4. package/dist/src/core/build-pet.js.map +1 -1
  5. package/dist/src/core/cli.js +456 -130
  6. package/dist/src/core/cli.js.map +1 -1
  7. package/dist/src/core/ensure-npmignore.d.ts +30 -0
  8. package/dist/src/core/ensure-npmignore.d.ts.map +1 -0
  9. package/dist/src/core/ensure-npmignore.js +121 -0
  10. package/dist/src/core/ensure-npmignore.js.map +1 -0
  11. package/dist/src/core/index.d.ts +6 -3
  12. package/dist/src/core/index.d.ts.map +1 -1
  13. package/dist/src/core/index.js +9 -3
  14. package/dist/src/core/index.js.map +1 -1
  15. package/dist/src/core/mcp-generator.d.ts +56 -0
  16. package/dist/src/core/mcp-generator.d.ts.map +1 -0
  17. package/dist/src/core/mcp-generator.js +1438 -0
  18. package/dist/src/core/mcp-generator.js.map +1 -0
  19. package/dist/src/core/mcp-server.js +0 -0
  20. package/dist/src/core/openapi-generator.d.ts +59 -0
  21. package/dist/src/core/openapi-generator.d.ts.map +1 -0
  22. package/dist/src/core/openapi-generator.js +800 -0
  23. package/dist/src/core/openapi-generator.js.map +1 -0
  24. package/dist/src/core/pet-config.d.ts +107 -49
  25. package/dist/src/core/pet-config.d.ts.map +1 -1
  26. package/dist/src/core/pet-config.js +6 -4
  27. package/dist/src/core/pet-config.js.map +1 -1
  28. package/dist/src/core/pet-downloader.d.ts +16 -0
  29. package/dist/src/core/pet-downloader.d.ts.map +1 -1
  30. package/dist/src/core/pet-downloader.js +145 -3
  31. package/dist/src/core/pet-downloader.js.map +1 -1
  32. package/dist/src/core/publish-pet.d.ts +29 -0
  33. package/dist/src/core/publish-pet.d.ts.map +1 -0
  34. package/dist/src/core/publish-pet.js +372 -0
  35. package/dist/src/core/publish-pet.js.map +1 -0
  36. package/dist/src/core/sdk-generator.d.ts +92 -0
  37. package/dist/src/core/sdk-generator.d.ts.map +1 -0
  38. package/dist/src/core/sdk-generator.js +567 -0
  39. package/dist/src/core/sdk-generator.js.map +1 -0
  40. package/dist/src/core/search-pets.d.ts +5 -0
  41. package/dist/src/core/search-pets.d.ts.map +1 -1
  42. package/dist/src/core/search-pets.js +43 -0
  43. package/dist/src/core/search-pets.js.map +1 -1
  44. package/dist/src/core/security-scanner.d.ts +49 -0
  45. package/dist/src/core/security-scanner.d.ts.map +1 -0
  46. package/dist/src/core/security-scanner.js +255 -0
  47. package/dist/src/core/security-scanner.js.map +1 -0
  48. package/dist/src/core/tool-lister.d.ts +61 -0
  49. package/dist/src/core/tool-lister.d.ts.map +1 -0
  50. package/dist/src/core/tool-lister.js +333 -0
  51. package/dist/src/core/tool-lister.js.map +1 -0
  52. package/dist/src/core/validate-pet.d.ts +2 -0
  53. package/dist/src/core/validate-pet.d.ts.map +1 -1
  54. package/dist/src/core/validate-pet.js +93 -1
  55. package/dist/src/core/validate-pet.js.map +1 -1
  56. package/dist/src/sdk/plugin-factory.d.ts +86 -0
  57. package/dist/src/sdk/plugin-factory.d.ts.map +1 -1
  58. package/dist/src/sdk/plugin-factory.js +450 -53
  59. package/dist/src/sdk/plugin-factory.js.map +1 -1
  60. package/dist/src/sdk/prompts-manager.d.ts +6 -0
  61. package/dist/src/sdk/prompts-manager.d.ts.map +1 -0
  62. package/dist/src/sdk/prompts-manager.js +162 -0
  63. package/dist/src/sdk/prompts-manager.js.map +1 -0
  64. package/package.json +1 -1
  65. package/dist/src/core/local-cache.d.ts +0 -69
  66. package/dist/src/core/local-cache.d.ts.map +0 -1
  67. package/dist/src/core/local-cache.js +0 -212
  68. package/dist/src/core/local-cache.js.map +0 -1
  69. package/dist/src/core/plugin-factory.d.ts +0 -58
  70. package/dist/src/core/plugin-factory.d.ts.map +0 -1
  71. package/dist/src/core/plugin-factory.js +0 -212
  72. 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