diff --git a/scripts/generate-tools.ts b/scripts/generate-tools.ts index 581f46c..57ab184 100644 --- a/scripts/generate-tools.ts +++ b/scripts/generate-tools.ts @@ -65,7 +65,7 @@ function formatTitle(operationId: string): string { function getZodSchema(op: OperationObject, method: string): string { if (method === "post" && op.requestBody?.content?.["application/json"]?.schema) { - const schema = op.requestBody.content["application/json"].schema; + const schema = normalizeToolSchema(op.requestBody.content["application/json"].schema); return jsonSchemaToZod(schema); } @@ -76,7 +76,10 @@ function getZodSchema(op: OperationObject, method: string): string { for (const param of op.parameters) { const paramSchema = typeof param.schema === "object" && param.schema !== null - ? { ...param.schema, ...(param.description ? { description: param.description } : {}) } + ? normalizeToolSchema({ + ...param.schema, + ...(param.description ? { description: param.description } : {}), + }) : param.schema; properties[param.name] = paramSchema; if (param.required) { @@ -94,6 +97,33 @@ function getZodSchema(op: OperationObject, method: string): string { return "z.object({})"; } +const DOKPLOY_EMAIL_LOOKAROUND_PATTERN = + "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"; + +function normalizeToolSchema(schema: JsonSchema): JsonSchema { + const normalized = structuredClone(schema) as JsonSchema; + stripProviderIncompatibleEmailPatterns(normalized); + return normalized; +} + +function stripProviderIncompatibleEmailPatterns(schema: unknown): void { + if (schema === null || typeof schema !== "object") return; + + if (Array.isArray(schema)) { + for (const item of schema) stripProviderIncompatibleEmailPatterns(item); + return; + } + + const record = schema as Record; + if (record.format === "email" && record.pattern === DOKPLOY_EMAIL_LOOKAROUND_PATTERN) { + delete record.pattern; + } + + for (const value of Object.values(record)) { + stripProviderIncompatibleEmailPatterns(value); + } +} + interface JsonSchemaObject { type?: string; properties?: Record; diff --git a/src/generated/tools.ts b/src/generated/tools.ts index a141bc0..2ae1f7f 100644 --- a/src/generated/tools.ts +++ b/src/generated/tools.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED FILE — DO NOT EDIT MANUALLY -// Generated from openapi.json on 2026-04-25 +// Generated from openapi.json on 2026-06-01 // Run `pnpm generate` to regenerate import { z } from "zod"; @@ -540,7 +540,7 @@ export const generatedTools: ToolDefinition[] = [ tag: "bitbucket", method: "POST", path: "/bitbucket.create", - schema: z.object({ "bitbucketId": z.string().optional(), "bitbucketUsername": z.string().optional(), "bitbucketEmail": z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")).optional(), "appPassword": z.string().optional(), "apiToken": z.string().optional(), "bitbucketWorkspaceName": z.string().optional(), "gitProviderId": z.string().optional(), "authId": z.string().min(1), "name": z.string().min(1) }), + schema: z.object({ "bitbucketId": z.string().optional(), "bitbucketUsername": z.string().optional(), "bitbucketEmail": z.string().email().optional(), "appPassword": z.string().optional(), "apiToken": z.string().optional(), "bitbucketWorkspaceName": z.string().optional(), "gitProviderId": z.string().optional(), "authId": z.string().min(1), "name": z.string().min(1) }), annotations: { title: "Bitbucket Create", ...{"openWorldHint":true}, @@ -600,7 +600,7 @@ export const generatedTools: ToolDefinition[] = [ tag: "bitbucket", method: "POST", path: "/bitbucket.testConnection", - schema: z.object({ "bitbucketId": z.string().min(1), "bitbucketUsername": z.string().optional(), "bitbucketEmail": z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")).optional(), "workspaceName": z.string().optional(), "apiToken": z.string().optional(), "appPassword": z.string().optional() }), + schema: z.object({ "bitbucketId": z.string().min(1), "bitbucketUsername": z.string().optional(), "bitbucketEmail": z.string().email().optional(), "workspaceName": z.string().optional(), "apiToken": z.string().optional(), "appPassword": z.string().optional() }), annotations: { title: "Bitbucket TestConnection", ...{"idempotentHint":true,"openWorldHint":true}, @@ -612,7 +612,7 @@ export const generatedTools: ToolDefinition[] = [ tag: "bitbucket", method: "POST", path: "/bitbucket.update", - schema: z.object({ "bitbucketId": z.string().min(1), "bitbucketUsername": z.string().optional(), "bitbucketEmail": z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")).optional(), "appPassword": z.string().optional(), "apiToken": z.string().optional(), "bitbucketWorkspaceName": z.string().optional(), "gitProviderId": z.string(), "name": z.string().min(1), "organizationId": z.string().optional() }), + schema: z.object({ "bitbucketId": z.string().min(1), "bitbucketUsername": z.string().optional(), "bitbucketEmail": z.string().email().optional(), "appPassword": z.string().optional(), "apiToken": z.string().optional(), "bitbucketWorkspaceName": z.string().optional(), "gitProviderId": z.string(), "name": z.string().min(1), "organizationId": z.string().optional() }), annotations: { title: "Bitbucket Update", ...{"idempotentHint":true,"openWorldHint":true}, @@ -4284,7 +4284,7 @@ export const generatedTools: ToolDefinition[] = [ tag: "settings", method: "POST", path: "/settings.assignDomainServer", - schema: z.object({ "host": z.string(), "certificateType": z.enum(["letsencrypt","none","custom"]), "letsEncryptEmail": z.union([z.union([z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")), z.literal("")]), z.null()]).optional(), "https": z.boolean().optional() }), + schema: z.object({ "host": z.string(), "certificateType": z.enum(["letsencrypt","none","custom"]), "letsEncryptEmail": z.union([z.union([z.string().email(), z.literal("")]), z.null()]).optional(), "https": z.boolean().optional() }), annotations: { title: "Settings AssignDomainServer", ...{"idempotentHint":true,"openWorldHint":true}, @@ -5028,7 +5028,7 @@ export const generatedTools: ToolDefinition[] = [ tag: "user", method: "POST", path: "/user.update", - schema: z.object({ "id": z.string().min(1).optional(), "firstName": z.string().optional(), "lastName": z.string().optional(), "isRegistered": z.boolean().optional(), "expirationDate": z.string().optional(), "createdAt2": z.string().optional(), "createdAt": z.union([z.string(), z.null()]).optional(), "twoFactorEnabled": z.union([z.boolean(), z.null()]).optional(), "email": z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")).min(1).optional(), "emailVerified": z.boolean().optional(), "image": z.union([z.string(), z.null()]).optional(), "banned": z.union([z.boolean(), z.null()]).optional(), "banReason": z.union([z.string(), z.null()]).optional(), "banExpires": z.union([z.string(), z.null()]).optional(), "updatedAt": z.string().optional(), "enablePaidFeatures": z.boolean().optional(), "allowImpersonation": z.boolean().optional(), "enableEnterpriseFeatures": z.boolean().optional(), "licenseKey": z.union([z.string(), z.null()]).optional(), "stripeCustomerId": z.union([z.string(), z.null()]).optional(), "stripeSubscriptionId": z.union([z.string(), z.null()]).optional(), "serversQuantity": z.number().optional(), "sendInvoiceNotifications": z.boolean().optional(), "password": z.string().optional(), "currentPassword": z.string().optional() }), + schema: z.object({ "id": z.string().min(1).optional(), "firstName": z.string().optional(), "lastName": z.string().optional(), "isRegistered": z.boolean().optional(), "expirationDate": z.string().optional(), "createdAt2": z.string().optional(), "createdAt": z.union([z.string(), z.null()]).optional(), "twoFactorEnabled": z.union([z.boolean(), z.null()]).optional(), "email": z.string().email().min(1).optional(), "emailVerified": z.boolean().optional(), "image": z.union([z.string(), z.null()]).optional(), "banned": z.union([z.boolean(), z.null()]).optional(), "banReason": z.union([z.string(), z.null()]).optional(), "banExpires": z.union([z.string(), z.null()]).optional(), "updatedAt": z.string().optional(), "enablePaidFeatures": z.boolean().optional(), "allowImpersonation": z.boolean().optional(), "enableEnterpriseFeatures": z.boolean().optional(), "licenseKey": z.union([z.string(), z.null()]).optional(), "stripeCustomerId": z.union([z.string(), z.null()]).optional(), "stripeSubscriptionId": z.union([z.string(), z.null()]).optional(), "serversQuantity": z.number().optional(), "sendInvoiceNotifications": z.boolean().optional(), "password": z.string().optional(), "currentPassword": z.string().optional() }), annotations: { title: "User Update", ...{"idempotentHint":true,"openWorldHint":true}, @@ -5160,7 +5160,7 @@ export const generatedTools: ToolDefinition[] = [ tag: "user", method: "POST", path: "/user.createUserWithCredentials", - schema: z.object({ "email": z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")), "password": z.string().min(8), "role": z.string().min(1) }), + schema: z.object({ "email": z.string().email(), "password": z.string().min(8), "role": z.string().min(1) }), annotations: { title: "User CreateUserWithCredentials", ...{"openWorldHint":true}, @@ -5412,7 +5412,7 @@ export const generatedTools: ToolDefinition[] = [ tag: "organization", method: "POST", path: "/organization.inviteMember", - schema: z.object({ "email": z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")), "role": z.string().min(1) }), + schema: z.object({ "email": z.string().email(), "role": z.string().min(1) }), annotations: { title: "Organization InviteMember", ...{"idempotentHint":true,"openWorldHint":true}, diff --git a/src/server.test.ts b/src/server.test.ts index 663dbad..f7a7476 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -13,15 +13,24 @@ vi.mock("./utils/apiClient.js", () => ({ const { createServer } = await import("./server.js"); describe("MCP server tools/list", () => { - async function getToolList() { + async function withClient(fn: (client: Client) => Promise) { const server = createServer(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); const client = new Client({ name: "test-client", version: "1.0.0" }); await client.connect(clientTransport); - const { tools } = await client.listTools(); - await client.close(); - return tools; + try { + return await fn(client); + } finally { + await client.close(); + } + } + + async function getToolList() { + return withClient(async (client) => { + const { tools } = await client.listTools(); + return tools; + }); } it("returns tools", async () => { @@ -33,10 +42,9 @@ describe("MCP server tools/list", () => { const tools = await getToolList(); for (const tool of tools) { const schema = tool.inputSchema as Record; - expect( - schema.$schema, - `Tool "${tool.name}" is missing $schema or has wrong draft`, - ).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.$schema, `Tool "${tool.name}" is missing $schema or has wrong draft`).toBe( + "https://json-schema.org/draft/2020-12/schema", + ); } }); @@ -60,7 +68,10 @@ describe("MCP server tools/list", () => { for (const tool of tools) { const found = findNestedSchemaKeys(tool.inputSchema); - expect(found, `Tool "${tool.name}" has nested $schema keys at: ${found.join(", ")}`).toHaveLength(0); + expect( + found, + `Tool "${tool.name}" has nested $schema keys at: ${found.join(", ")}`, + ).toHaveLength(0); } }); @@ -75,4 +86,42 @@ describe("MCP server tools/list", () => { ).toBe("object"); } }); + + it("does not emit pattern keywords in tool input schemas", async () => { + const tools = await getToolList(); + + function findPatternKeys(obj: unknown, path = ""): string[] { + if (obj === null || typeof obj !== "object") return []; + if (Array.isArray(obj)) { + return obj.flatMap((item, i) => findPatternKeys(item, `${path}[${i}]`)); + } + + const record = obj as Record; + const found: string[] = []; + for (const [key, value] of Object.entries(record)) { + const currentPath = path ? `${path}.${key}` : key; + if (key === "pattern") { + found.push(`${currentPath}: ${value}`); + } + found.push(...findPatternKeys(value, currentPath)); + } + return found; + } + + for (const tool of tools) { + const found = findPatternKeys(tool.inputSchema); + expect( + found, + `Tool "${tool.name}" has provider-incompatible pattern keywords at: ${found.join(", ")}`, + ).toHaveLength(0); + } + }); + + it("returns empty resource and prompt lists for clients that query them", async () => { + await withClient(async (client) => { + await expect(client.listResources()).resolves.toEqual({ resources: [] }); + await expect(client.listResourceTemplates()).resolves.toEqual({ resourceTemplates: [] }); + await expect(client.listPrompts()).resolves.toEqual({ prompts: [] }); + }); + }); }); diff --git a/src/server.ts b/src/server.ts index 4af1f3b..4c19d4a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; import type { ZodObject, ZodRawShape } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { generatedTools } from "./generated/tools.js"; @@ -51,6 +56,23 @@ function stripNestedSchemaKeys(value: unknown): void { } } +function stripPatternKeys(value: unknown): void { + if (value === null || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) stripPatternKeys(item); + return; + } + + const record = value as Record; + for (const key of Object.keys(record)) { + if (key === "pattern") { + delete record[key]; + } else { + stripPatternKeys(record[key]); + } + } +} + // Claude's API requires JSON Schema draft 2020-12. The MCP SDK's built-in // Zod→JSON Schema converter emits draft-07 by default, which causes a 400 // error on tools/list. We bypass the SDK's auto-generated handler by @@ -63,15 +85,24 @@ function toDraft2020_12JsonSchema(schema: ZodObject): Record; stripNestedSchemaKeys(result); + stripPatternKeys(result); result.$schema = JSON_SCHEMA_2020_12; return result; } export function createServer() { - const server = new McpServer({ - name: "dokploy", - version: "2.0.0", - }); + const server = new McpServer( + { + name: "dokploy", + version: "2.0.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + }, + }, + ); const tools = getEnabledTools(); @@ -96,5 +127,17 @@ export function createServer() { tools: toolList, })); + server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [], + })); + + server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ + resourceTemplates: [], + })); + + server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: [], + })); + return server; }