From b3f27f2410df62f6bace149982f9c6016708a73a Mon Sep 17 00:00:00 2001 From: franktronicsprog Date: Sat, 13 Jun 2026 11:41:55 +0200 Subject: [PATCH] feat: add toolset presets for large MCP clients --- README.md | 33 ++++++++++++++++++-- src/server.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++++---- src/server.ts | 75 +++++++++++++++++++++++++++++++++++++------- 3 files changed, 166 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index adf1224..b303795 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,9 @@ The configuration on Windows is slightly different compared to Linux or macOS. U | `DOKPLOY_URL` | Yes | Your Dokploy server URL (e.g., `https://your-dokploy-server.com`) | | `DOKPLOY_API_KEY` | Yes | Your Dokploy API authentication token | | `DOKPLOY_CUSTOM_HEADERS` | No | JSON object of additional upstream request headers. Header names and values must be strings. Reserved headers cannot be set here: `x-api-key`, `content-type`, `accept`. | +| `DOKPLOY_TOOL_PRESET` | No | Predefined toolset to load: `all` (default), `minimal`, `core`, `deploy`, `databases`, or `git`. Useful for clients/providers that struggle with very large tool lists. | | `DOKPLOY_ENABLED_TAGS` | No | Comma-separated list of tags to filter which tools are loaded (e.g., `project,application,postgres`) | +| `DOKPLOY_DISABLED_TAGS` | No | Comma-separated list of tags to exclude from the selected toolset. Applied after `DOKPLOY_TOOL_PRESET` or `DOKPLOY_ENABLED_TAGS`. | | `DOKPLOY_TIMEOUT` | No | Request timeout in milliseconds (default: `30000`) | | `DOKPLOY_RETRY_ATTEMPTS` | No | Number of retry attempts (default: `3`) | | `DOKPLOY_RETRY_DELAY` | No | Delay between retries in milliseconds (default: `1000`) | @@ -423,13 +425,40 @@ This MCP server provides **508 tools** covering the entire Dokploy API, organize ### Tool Filtering -You can limit which tools are loaded by setting the `DOKPLOY_ENABLED_TAGS` environment variable. This is useful when you only need a subset of tools: +By default, the server exposes all Dokploy API tools. Some MCP clients and LLM providers can be slower or less reliable when very large tool lists are sent to the model. You can reduce the loaded tools with presets or tag filters. + +Use `DOKPLOY_TOOL_PRESET` for common workflows: + +| Preset | Included tags | +|--------|---------------| +| `all` | All tools (default) | +| `minimal` | `project`, `application` | +| `core` | `project`, `server`, `application` | +| `deploy` | `project`, `environment`, `server`, `application`, `domain`, `deployment` | +| `databases` | `postgres`, `redis`, `mysql`, `mariadb`, `mongo`, `libsql` | +| `git` | `github`, `gitlab`, `bitbucket`, `gitea`, `gitProvider`, `registry`, `sshKey` | + +```bash +# Recommended starting point for clients/providers sensitive to large toolsets +DOKPLOY_TOOL_PRESET=minimal +``` + +For exact control, set `DOKPLOY_ENABLED_TAGS`: ```bash # Only load project, application, and postgres tools DOKPLOY_ENABLED_TAGS=project,application,postgres ``` +You can also remove categories from a preset: + +```bash +DOKPLOY_TOOL_PRESET=core +DOKPLOY_DISABLED_TAGS=postgres,redis +``` + +If `DOKPLOY_ENABLED_TAGS` is set, it takes precedence over `DOKPLOY_TOOL_PRESET`. `DOKPLOY_DISABLED_TAGS` is applied last. + All tools include semantic annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`) to help MCP clients understand their behavior and safety characteristics. ## Architecture @@ -499,7 +528,7 @@ npx -y @modelcontextprotocol/inspector npx @dokploy/mcp 3. Verify your `DOKPLOY_URL` and `DOKPLOY_API_KEY` environment variables are correctly set. -4. If too many tools are loading, use `DOKPLOY_ENABLED_TAGS` to filter by category. +4. If too many tools are loading or your provider times out while processing tools, start with `DOKPLOY_TOOL_PRESET=minimal`, then use `DOKPLOY_ENABLED_TAGS` for exact category filtering if needed. ## Contributing diff --git a/src/server.test.ts b/src/server.test.ts index 663dbad..1bb97fe 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, 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. @@ -13,6 +13,14 @@ vi.mock("./utils/apiClient.js", () => ({ const { createServer } = await import("./server.js"); describe("MCP server tools/list", () => { + const toolsetEnvVars = ["DOKPLOY_ENABLED_TAGS", "DOKPLOY_DISABLED_TAGS", "DOKPLOY_TOOL_PRESET"]; + + afterEach(() => { + for (const envVar of toolsetEnvVars) { + delete process.env[envVar]; + } + }); + async function getToolList() { const server = createServer(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -29,14 +37,69 @@ describe("MCP server tools/list", () => { expect(tools.length).toBeGreaterThan(0); }); + it("returns all tools by default", async () => { + const tools = await getToolList(); + expect(tools).toHaveLength(524); + }); + + it("supports DOKPLOY_TOOL_PRESET=minimal for clients sensitive to large toolsets", async () => { + process.env.DOKPLOY_TOOL_PRESET = "minimal"; + + const tools = await getToolList(); + const tags = new Set(tools.map((tool) => tool.name.split("-")[0])); + + expect(tools).toHaveLength(40); + expect(tags).toEqual(new Set(["application", "project"])); + }); + + it("supports DOKPLOY_TOOL_PRESET=core for common application workflows", async () => { + process.env.DOKPLOY_TOOL_PRESET = "core"; + + const tools = await getToolList(); + const tags = new Set(tools.map((tool) => tool.name.split("-")[0])); + + expect(tools).toHaveLength(57); + expect(tags).toEqual(new Set(["application", "project", "server"])); + }); + + it("lets DOKPLOY_ENABLED_TAGS override presets", async () => { + process.env.DOKPLOY_TOOL_PRESET = "core"; + process.env.DOKPLOY_ENABLED_TAGS = "project,application"; + + const tools = await getToolList(); + const tags = new Set(tools.map((tool) => tool.name.split("-")[0])); + + expect(tools).toHaveLength(40); + expect(tags).toEqual(new Set(["application", "project"])); + }); + + it("excludes DOKPLOY_DISABLED_TAGS after selecting tools", async () => { + process.env.DOKPLOY_TOOL_PRESET = "deploy"; + process.env.DOKPLOY_DISABLED_TAGS = "domain,deployment"; + + const tools = await getToolList(); + const tags = new Set(tools.map((tool) => tool.name.split("-")[0])); + + expect(tools).toHaveLength(64); + expect(tags.has("domain")).toBe(false); + expect(tags.has("deployment")).toBe(false); + }); + + it("falls back to all tools for an unknown preset", async () => { + process.env.DOKPLOY_TOOL_PRESET = "unknown"; + + const tools = await getToolList(); + + expect(tools).toHaveLength(524); + }); + it("every tool inputSchema has $schema set to draft 2020-12", async () => { 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 +123,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); } }); diff --git a/src/server.ts b/src/server.ts index 4af1f3b..1b71a1c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,28 +9,79 @@ import { createLogger } from "./utils/logger.js"; const logger = createLogger("MCP-Server"); const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"; +const LARGE_TOOLSET_WARNING_THRESHOLD = 150; + +const TOOL_PRESETS = { + all: null, + minimal: "project,application", + core: "project,server,application", + deploy: "project,environment,server,application,domain,deployment", + databases: "postgres,redis,mysql,mariadb,mongo,libsql", + git: "github,gitlab,bitbucket,gitea,gitProvider,registry,sshKey", +} as const; + +type ToolPreset = keyof typeof TOOL_PRESETS; + +function parseTagList(value: string | undefined): Set { + return new Set( + (value ?? "") + .split(",") + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean), + ); +} + +function isToolPreset(value: string): value is ToolPreset { + return Object.hasOwn(TOOL_PRESETS, value); +} function getEnabledTools() { const enabledTags = process.env.DOKPLOY_ENABLED_TAGS; + const disabledTags = parseTagList(process.env.DOKPLOY_DISABLED_TAGS); + const requestedPreset = process.env.DOKPLOY_TOOL_PRESET?.trim().toLowerCase() || "all"; + const preset: ToolPreset = isToolPreset(requestedPreset) ? requestedPreset : "all"; + + if (!isToolPreset(requestedPreset)) { + logger.warn("Unknown tool preset, falling back to all tools", { + requestedPreset, + availablePresets: Object.keys(TOOL_PRESETS), + }); + } - if (!enabledTags) { - return generatedTools; + let selectedTags = parseTagList(enabledTags); + const source = selectedTags.size > 0 ? "enabled-tags" : "preset"; + + if (selectedTags.size === 0) { + selectedTags = parseTagList(TOOL_PRESETS[preset] ?? undefined); } - const tags = new Set( - enabledTags - .split(",") - .map((t) => t.trim().toLowerCase()) - .filter(Boolean), - ); + let filtered = + selectedTags.size > 0 + ? generatedTools.filter((tool) => selectedTags.has(tool.tag.toLowerCase())) + : generatedTools; - const filtered = generatedTools.filter((tool) => tags.has(tool.tag.toLowerCase())); + if (disabledTags.size > 0) { + filtered = filtered.filter((tool) => !disabledTags.has(tool.tag.toLowerCase())); + } - logger.info("Filtered tools by tags", { - enabledTags: [...tags], + const context = { total: generatedTools.length, loaded: filtered.length, - }); + source, + preset, + enabledTags: [...selectedTags], + disabledTags: [...disabledTags], + }; + + logger.info("Loaded tools", context); + + if (filtered.length > LARGE_TOOLSET_WARNING_THRESHOLD) { + logger.warn("Large toolset loaded; some MCP clients or LLM providers may time out", { + ...context, + recommendation: + "Set DOKPLOY_TOOL_PRESET=minimal or DOKPLOY_ENABLED_TAGS to reduce tool count", + }); + } return filtered; }