diff --git a/TOOLS.md b/TOOLS.md index fa69db1..cc69c5d 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -2,14 +2,14 @@ > Auto-generated from the [Dokploy OpenAPI spec](https://docs.dokploy.com/openapi.json). Run `pnpm generate` to update. -- **Total Tools**: 524 +- **Total Tools**: 525 - **Categories**: 48 ## Categories - [admin](#admin) (1 tools) - [ai](#ai) (12 tools) -- [application](#application) (31 tools) +- [application](#application) (32 tools) - [auditLog](#auditLog) (1 tools) - [backup](#backup) (12 tools) - [bitbucket](#bitbucket) (7 tools) @@ -91,6 +91,7 @@ | `application-start` | POST | `applicationId` (string) | | `application-redeploy` | POST | `applicationId` (string), `title`?, `description`? | | `application-saveEnvironment` | POST | `applicationId` (string), `env` (string | null), `buildArgs` (string | null), `buildSecrets` (string | null), `createEnvFile` (boolean) | +| `application-env-upsert` | POST | `applicationId` (string), `variables` (object), `redeploy`?, `dryRun`?, `expectedRevision`? | | `application-saveBuildType` | POST | `applicationId` (string), `buildType` ("dockerfile" | "heroku_buildpacks" | "paketo_buildpacks" | "nixpacks" | "static" | "railpack"), `dockerfile` (string | null), `dockerContextPath` (string | null), `dockerBuildStage` (string | null), `herokuVersion` (string | null), `railpackVersion` (string | null), `publishDirectory`?, `isStaticSpa`? | | `application-saveGithubProvider` | POST | `applicationId` (string), `repository` (string | null), `branch` (string | null), `owner` (string | null), `buildPath` (string | null), `githubId` (string | null), `triggerType` ("push" | "tag"), `enableSubmodules`?, `watchPaths`? | | `application-saveGitlabProvider` | POST | `applicationId` (string), `gitlabBranch` (string | null), `gitlabBuildPath` (string | null), `gitlabOwner` (string | null), `gitlabRepository` (string | null), `gitlabId` (string | null), `gitlabProjectId` (number | null), `gitlabPathNamespace` (string | null), `enableSubmodules`?, `watchPaths`? | diff --git a/scripts/generate-tools.ts b/scripts/generate-tools.ts index 581f46c..63911d2 100644 --- a/scripts/generate-tools.ts +++ b/scripts/generate-tools.ts @@ -66,7 +66,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; - return jsonSchemaToZod(schema); + return customZodObjectSchema(schema) ?? jsonSchemaToZod(schema); } if (op.parameters && op.parameters.length > 0) { @@ -101,6 +101,82 @@ interface JsonSchemaObject { anyOf?: JsonSchemaObject[]; enum?: string[]; items?: JsonSchemaObject; + additionalProperties?: JsonSchemaObject | boolean; + propertyNames?: JsonSchemaObject; + pattern?: string; + minLength?: number; + maxLength?: number; + minProperties?: number; +} + +function zodStringSchema(schema: JsonSchemaObject): string { + let zod = "z.string()"; + if (schema.pattern) { + zod += `.regex(new RegExp(${JSON.stringify(schema.pattern)}))`; + } + if (typeof schema.minLength === "number") { + zod += `.min(${schema.minLength})`; + } + if (typeof schema.maxLength === "number") { + zod += `.max(${schema.maxLength})`; + } + return zod; +} + +function customZodRecordSchema(schema: JsonSchemaObject): string | null { + if ( + schema.type !== "object" || + typeof schema.additionalProperties !== "object" || + schema.additionalProperties === null || + schema.additionalProperties.type !== "string" || + schema.propertyNames?.pattern === undefined + ) { + return null; + } + + let zod = `z.record(${zodStringSchema(schema.propertyNames)}, ${zodStringSchema(schema.additionalProperties)})`; + + if (typeof schema.minProperties === "number" && schema.minProperties > 0) { + zod += `.refine((value) => Object.keys(value).length >= ${schema.minProperties}, "At least ${schema.minProperties} propert${ + schema.minProperties === 1 ? "y is" : "ies are" + } required")`; + } + + return zod; +} + +function customZodObjectSchema(schema: JsonSchema): string | null { + if ( + typeof schema !== "object" || + schema === null || + schema.type !== "object" || + !schema.properties + ) { + return null; + } + + const objectSchema = schema as JsonSchemaObject; + const requiredSet = new Set(objectSchema.required || []); + const propertyEntries = Object.entries(objectSchema.properties).map(([name, propSchema]) => ({ + name, + propSchema, + customSchema: customZodRecordSchema(propSchema), + })); + + if (!propertyEntries.some((entry) => entry.customSchema !== null)) { + return null; + } + + const entries = propertyEntries.map(({ name, propSchema, customSchema }) => { + const propertySchema = customSchema ?? jsonSchemaToZod(propSchema as JsonSchema); + return `${JSON.stringify(name)}: ${propertySchema}${requiredSet.has(name) ? "" : ".optional()"}`; + }); + + let zod = `z.object({ ${entries.join(", ")} })`; + if (objectSchema.additionalProperties === false) { + zod += ".strict()"; + } + return zod; } interface ToolMdEntry { diff --git a/src/generated/openapi.json b/src/generated/openapi.json index 9a33316..202a578 100644 --- a/src/generated/openapi.json +++ b/src/generated/openapi.json @@ -1087,6 +1087,166 @@ } } }, + "/application/env/upsert": { + "post": { + "operationId": "application-env-upsert", + "tags": [ + "application" + ], + "security": [ + { + "x-api-key": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "applicationId": { + "type": "string", + "minLength": 1 + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "additionalProperties": { + "type": "string" + }, + "minProperties": 1 + }, + "redeploy": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "expectedRevision": { + "type": "string" + } + }, + "required": [ + "applicationId", + "variables" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "applicationId": { + "type": "string" + }, + "changed": { + "type": "boolean" + }, + "revision": { + "type": "string" + }, + "dryRun": { + "type": "boolean" + }, + "redeployed": { + "type": "boolean" + }, + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "created", + "updated", + "unchanged" + ] + }, + "secret": { + "type": "boolean" + } + }, + "required": [ + "name", + "action", + "secret" + ], + "additionalProperties": false + } + } + }, + "required": [ + "applicationId", + "changed", + "revision", + "dryRun", + "redeployed", + "variables" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, "/application.saveBuildType": { "post": { "operationId": "application-saveBuildType", @@ -57522,4 +57682,4 @@ "x-api-key": [] } ] -} \ No newline at end of file +} diff --git a/src/generated/tools.ts b/src/generated/tools.ts index a141bc0..f560eda 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-07-06 // Run `pnpm generate` to regenerate import { z } from "zod"; @@ -114,6 +114,18 @@ export const generatedTools: ToolDefinition[] = [ ...{"idempotentHint":true,"openWorldHint":true}, }, }, + { + name: "application-env-upsert", + description: "POST /application/env/upsert", + tag: "application", + method: "POST", + path: "/application/env/upsert", + schema: z.object({ "applicationId": z.string().min(1), "variables": z.record(z.string().regex(new RegExp("^[A-Za-z_][A-Za-z0-9_]*$")), z.string()).refine((value) => Object.keys(value).length >= 1, "At least 1 property is required"), "redeploy": z.boolean().optional(), "dryRun": z.boolean().optional(), "expectedRevision": z.string().optional() }), + annotations: { + title: "Application Env Upsert", + ...{"idempotentHint":true,"openWorldHint":true}, + }, + }, { name: "application-saveBuildType", description: "POST /application.saveBuildType", diff --git a/src/handler.ts b/src/handler.ts index 0af976c..1f0758c 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -7,10 +7,27 @@ import { ResponseFormatter } from "./utils/responseFormatter.js"; const logger = createLogger("ToolHandler"); +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function getRedactFieldsForTool(tool: ToolDefinition, input: Record) { + if (tool.name !== "application-env-upsert" || !isRecord(input.variables)) { + return []; + } + + return Object.keys(input.variables); +} + export function createHandler(tool: ToolDefinition) { return async (input: Record) => { const { redactEnv, redactFields } = getClientConfig(); - const redact = (value: T): T => (redactEnv ? redactSensitive(value, redactFields) : value); + const toolRedactFields = getRedactFieldsForTool(tool, input); + const effectiveRedactFields = redactEnv + ? [...new Set([...redactFields, ...toolRedactFields])] + : toolRedactFields; + const redact = (value: T): T => + effectiveRedactFields.length > 0 ? redactSensitive(value, effectiveRedactFields) : value; try { logger.info(`Executing tool: ${tool.name}`, { input: redact(input) }); diff --git a/src/server.test.ts b/src/server.test.ts index 663dbad..17eb05c 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,6 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock apiClient before server.ts is imported — it calls getClientConfig() at // module level which requires DOKPLOY_URL/DOKPLOY_API_KEY env vars. @@ -10,15 +10,61 @@ vi.mock("./utils/apiClient.js", () => ({ clearAuthToken: vi.fn(), })); +const { default: apiClient } = await import("./utils/apiClient.js"); +const { generatedTools } = await import("./generated/tools.js"); const { createServer } = await import("./server.js"); describe("MCP server tools/list", () => { - async function getToolList() { + const originalDokployUrl = process.env.DOKPLOY_URL; + const originalDokployApiKey = process.env.DOKPLOY_API_KEY; + const originalDokployRedactEnv = process.env.DOKPLOY_REDACT_ENV; + const originalDokployRedactFields = process.env.DOKPLOY_REDACT_FIELDS; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.DOKPLOY_URL = "https://dokploy.example"; + process.env.DOKPLOY_API_KEY = "test-api-key"; + process.env.DOKPLOY_REDACT_ENV = "false"; + delete process.env.DOKPLOY_REDACT_FIELDS; + }); + + afterEach(() => { + if (originalDokployUrl === undefined) { + delete process.env.DOKPLOY_URL; + } else { + process.env.DOKPLOY_URL = originalDokployUrl; + } + + if (originalDokployApiKey === undefined) { + delete process.env.DOKPLOY_API_KEY; + } else { + process.env.DOKPLOY_API_KEY = originalDokployApiKey; + } + + if (originalDokployRedactEnv === undefined) { + delete process.env.DOKPLOY_REDACT_ENV; + } else { + process.env.DOKPLOY_REDACT_ENV = originalDokployRedactEnv; + } + + if (originalDokployRedactFields === undefined) { + delete process.env.DOKPLOY_REDACT_FIELDS; + } else { + process.env.DOKPLOY_REDACT_FIELDS = originalDokployRedactFields; + } + }); + + async function createConnectedClient() { 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); + return client; + } + + async function getToolList() { + const client = await createConnectedClient(); const { tools } = await client.listTools(); await client.close(); return tools; @@ -33,10 +79,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 +105,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 +123,130 @@ describe("MCP server tools/list", () => { ).toBe("object"); } }); + + it("exposes application env upsert with partial-update inputs", async () => { + const tools = await getToolList(); + const tool = tools.find((candidate) => candidate.name === "application-env-upsert"); + + expect(tool).toBeDefined(); + expect(tool?.description).toBe("POST /application/env/upsert"); + + const schema = tool?.inputSchema as Record; + const properties = schema.properties as Record>; + + expect(schema.required).toEqual(["applicationId", "variables"]); + expect(properties.applicationId.type).toBe("string"); + expect(properties.variables.type).toBe("object"); + expect(properties.variables.additionalProperties).toMatchObject({ + type: "string", + }); + expect(properties.variables.propertyNames).toMatchObject({ + pattern: "^[A-Za-z_][A-Za-z0-9_]*$", + }); + expect(properties.redeploy.type).toBe("boolean"); + expect(properties.dryRun.type).toBe("boolean"); + expect(properties.expectedRevision.type).toBe("string"); + + const generatedTool = generatedTools.find( + (candidate) => candidate.name === "application-env-upsert", + ); + expect(generatedTool).toBeDefined(); + expect( + generatedTool?.schema.safeParse({ + applicationId: "app_1", + variables: { + REDIS_PASSWORD: "placeholder-secret-value", + }, + }).success, + ).toBe(true); + expect( + generatedTool?.schema.safeParse({ + applicationId: "app_1", + variables: {}, + }).success, + ).toBe(false); + expect( + generatedTool?.schema.safeParse({ + applicationId: "app_1", + variables: { + "1_BAD": "placeholder-secret-value", + }, + }).success, + ).toBe(false); + }); + + it("routes application env upsert without full environment replacement or raw value output", async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { + applicationId: "app_1", + changed: true, + revision: "env:next", + dryRun: true, + redeployed: false, + variables: [ + { + name: "REDIS_PASSWORD", + action: "updated", + secret: true, + }, + ], + }, + }); + + const client = await createConnectedClient(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await client.callTool({ + name: "application-env-upsert", + arguments: { + applicationId: "app_1", + variables: { + REDIS_PASSWORD: "placeholder-secret-value", + }, + dryRun: true, + redeploy: false, + expectedRevision: "env:current", + }, + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + expect(apiClient.post).toHaveBeenCalledTimes(1); + expect(apiClient.post).toHaveBeenCalledWith("/application/env/upsert", { + applicationId: "app_1", + variables: { + REDIS_PASSWORD: "placeholder-secret-value", + }, + dryRun: true, + redeploy: false, + expectedRevision: "env:current", + }); + expect(apiClient.post).not.toHaveBeenCalledWith( + "/application.saveEnvironment", + expect.anything(), + ); + + const [, postBody] = vi.mocked(apiClient.post).mock.calls[0] as [ + string, + Record, + ]; + expect(postBody).not.toHaveProperty("env"); + expect(postBody).not.toHaveProperty("buildArgs"); + expect(postBody).not.toHaveProperty("buildSecrets"); + expect(postBody).not.toHaveProperty("createEnvFile"); + + const responseText = result.content + .map((item) => (item.type === "text" ? item.text : "")) + .join("\n"); + const logText = consoleError.mock.calls.map((call) => String(call[0])).join("\n"); + + expect(responseText).toContain('"applicationId": "app_1"'); + expect(responseText).toContain('"secret": true'); + expect(responseText).not.toContain("placeholder-secret-value"); + expect(logText).toContain('"REDIS_PASSWORD":"[REDACTED]"'); + expect(logText).not.toContain("placeholder-secret-value"); + } finally { + consoleError.mockRestore(); + await client.close(); + } + }); });