diff --git a/README.md b/README.md index 7d08ff3..616723f 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ For detailed transport mode documentation and client examples, refer to the conf ## 📚 Available Tools -This MCP server provides **67 tools** organized into five main categories: +This MCP server provides **69 tools** organized into five main categories: ### 🗂️ Project Management (6 tools) @@ -347,7 +347,7 @@ This MCP server provides **67 tools** organized into five main categories: - `project-duplicate` - Duplicate project with optional service selection - `project-remove` - Delete project -### 🚀 Application Management (26 tools) +### 🚀 Application Management (28 tools) **Core Operations:** - `application-one`, `application-create`, `application-update`, `application-delete` @@ -360,7 +360,8 @@ This MCP server provides **67 tools** organized into five main categories: **Configuration:** - `application-saveBuildType`, `application-saveEnvironment`, `application-saveDockerProvider` -- `application-readAppMonitoring`, `application-readTraefikConfig`, `application-updateTraefikConfig` +- `application-readAppMonitoring`, `application-readContainerLogs`, `application-readDeploymentLogs` +- `application-readTraefikConfig`, `application-updateTraefikConfig` - `application-refreshToken`, `application-cleanQueues` ### 🌐 Domain Management (9 tools) @@ -402,7 +403,7 @@ For detailed schemas, parameters, and usage examples, see **[TOOLS.md](TOOLS.md) Built with **@modelcontextprotocol/sdk**, **TypeScript**, and **Zod** for type-safe schema validation: -- **67 Tools** covering projects, applications, domains, PostgreSQL, and MySQL management +- **69 Tools** covering projects, applications, domains, PostgreSQL, and MySQL management - **Multiple Transports**: Stdio (default) and HTTP (Streamable HTTP + legacy SSE) - **Multiple Git Providers**: GitHub, GitLab, Bitbucket, Gitea, custom Git - **Robust Error Handling**: Centralized API client with retry logic diff --git a/TOOLS.md b/TOOLS.md index feca276..b632641 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -4,9 +4,9 @@ This document provides detailed information about all available tools in the Dok ## 📊 Overview -- **Total Tools**: 67 +- **Total Tools**: 69 - **Project Tools**: 6 -- **Application Tools**: 26 +- **Application Tools**: 28 - **Domain Tools**: 9 - **PostgreSQL Tools**: 13 - **MySQL Tools**: 13 @@ -431,6 +431,42 @@ All tools include semantic annotations (`readOnlyHint`, `destructiveHint`, `idem - **Annotations**: Read-only, Idempotent - **Required Fields**: `appName` +#### `application-readContainerLogs` + +- **Description**: Reads real-time container logs via Dokploy websocket and returns a snapshot +- **Input Schema**: + ```json + { + "containerId": "string", + "runType": "native|swarm", + "serverId": "string", + "tail": "string", + "since": "string", + "search": "string", + "timeoutMs": "number", + "maxChars": "number" + } + ``` +- **Annotations**: Read-only, Idempotent +- **Required Fields**: `containerId` +- **Optional Fields**: `runType`, `serverId`, `tail`, `since`, `search`, `timeoutMs`, `maxChars` + +#### `application-readDeploymentLogs` + +- **Description**: Reads deployment logs via Dokploy websocket and returns a snapshot +- **Input Schema**: + ```json + { + "logPath": "string", + "serverId": "string", + "timeoutMs": "number", + "maxChars": "number" + } + ``` +- **Annotations**: Read-only, Idempotent +- **Required Fields**: `logPath` +- **Optional Fields**: `serverId`, `timeoutMs`, `maxChars` + #### `application-readTraefikConfig` - **Description**: Reads Traefik configuration for an application diff --git a/package.json b/package.json index 7fa52e5..bced0ed 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,14 @@ "@modelcontextprotocol/sdk": "^1.12.0", "axios": "^1.9.0", "express": "^5.1.0", + "ws": "^8.18.3", "zod": "^3.25.28" }, "devDependencies": { "@types/eslint": "^9.6.1", "@types/express": "^5.0.2", "@types/node": "^22.15.21", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", "eslint": "^9.27.0", diff --git a/src/http-server.ts b/src/http-server.ts index d386742..444dc9f 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -63,7 +63,7 @@ export async function main() { const server = createServer(); // The transport will have sessionId after initialization await server.connect( - transport as StreamableHTTPServerTransport & { sessionId: string } + transport as unknown as Parameters[0] ); // Log after successful connection diff --git a/src/mcp/tools/application/applicationReadContainerLogs.ts b/src/mcp/tools/application/applicationReadContainerLogs.ts new file mode 100644 index 0000000..b5faea1 --- /dev/null +++ b/src/mcp/tools/application/applicationReadContainerLogs.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { ResponseFormatter } from "../../../utils/responseFormatter.js"; +import { collectWebSocketLogs } from "../../../utils/websocketLogs.js"; +import { createTool } from "../toolFactory.js"; + +export const applicationReadContainerLogs = createTool({ + name: "application-readContainerLogs", + description: + "Reads real-time container logs via Dokploy websocket and returns a snapshot.", + schema: z.object({ + containerId: z + .string() + .describe("Container ID to stream logs from (required)."), + runType: z + .enum(["native", "swarm"]) + .optional() + .describe("Container runtime mode. Defaults to 'native'."), + serverId: z + .string() + .optional() + .describe("Optional Dokploy server ID for remote hosts."), + tail: z + .union([z.string(), z.number().int().positive()]) + .optional() + .transform((value) => (value === undefined ? undefined : String(value))) + .describe("Number of lines to read from end before follow (default: 100)."), + since: z + .string() + .optional() + .describe("Time window, e.g. 'all', '10m', '1h' (default: all)."), + search: z + .string() + .optional() + .describe("Optional case-insensitive text filter."), + timeoutMs: z + .number() + .int() + .min(1000) + .max(30000) + .optional() + .describe("How long to collect log stream before returning (default: 5000)."), + maxChars: z + .number() + .int() + .min(500) + .max(200000) + .optional() + .describe("Maximum number of log characters to return (default: 30000)."), + }), + annotations: { + title: "Read Application Container Logs", + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (input) => { + const result = await collectWebSocketLogs({ + path: "/docker-container-logs", + query: { + containerId: input.containerId, + runType: input.runType ?? "native", + serverId: input.serverId, + tail: input.tail ?? "100", + since: input.since ?? "all", + search: input.search ?? "", + }, + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.maxChars !== undefined ? { maxChars: input.maxChars } : {}), + }); + + if (!result.logs) { + return ResponseFormatter.success( + `No logs received for container "${input.containerId}" in the selected time window.`, + { + containerId: input.containerId, + messageCount: result.messageCount, + timedOut: result.timedOut, + } + ); + } + + return ResponseFormatter.success( + `Collected logs for container "${input.containerId}".`, + { + containerId: input.containerId, + messageCount: result.messageCount, + timedOut: result.timedOut, + truncated: result.truncated, + logs: result.logs, + } + ); + }, +}); diff --git a/src/mcp/tools/application/applicationReadDeploymentLogs.ts b/src/mcp/tools/application/applicationReadDeploymentLogs.ts new file mode 100644 index 0000000..b736aa0 --- /dev/null +++ b/src/mcp/tools/application/applicationReadDeploymentLogs.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { ResponseFormatter } from "../../../utils/responseFormatter.js"; +import { collectWebSocketLogs } from "../../../utils/websocketLogs.js"; +import { createTool } from "../toolFactory.js"; + +export const applicationReadDeploymentLogs = createTool({ + name: "application-readDeploymentLogs", + description: + "Reads deployment logs via Dokploy websocket and returns a snapshot.", + schema: z.object({ + logPath: z + .string() + .describe("Deployment log path provided by Dokploy deployment metadata."), + serverId: z + .string() + .optional() + .describe("Optional Dokploy server ID for remote hosts."), + timeoutMs: z + .number() + .int() + .min(1000) + .max(30000) + .optional() + .describe("How long to collect log stream before returning (default: 5000)."), + maxChars: z + .number() + .int() + .min(500) + .max(200000) + .optional() + .describe("Maximum number of log characters to return (default: 30000)."), + }), + annotations: { + title: "Read Application Deployment Logs", + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (input) => { + const result = await collectWebSocketLogs({ + path: "/listen-deployment", + query: { + logPath: input.logPath, + serverId: input.serverId, + }, + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.maxChars !== undefined ? { maxChars: input.maxChars } : {}), + }); + + if (!result.logs) { + return ResponseFormatter.success( + `No deployment logs received for path "${input.logPath}" in the selected time window.`, + { + logPath: input.logPath, + messageCount: result.messageCount, + timedOut: result.timedOut, + } + ); + } + + return ResponseFormatter.success( + `Collected deployment logs for path "${input.logPath}".`, + { + logPath: input.logPath, + messageCount: result.messageCount, + timedOut: result.timedOut, + truncated: result.truncated, + logs: result.logs, + } + ); + }, +}); diff --git a/src/mcp/tools/application/index.ts b/src/mcp/tools/application/index.ts index 96a5b64..0a3a742 100644 --- a/src/mcp/tools/application/index.ts +++ b/src/mcp/tools/application/index.ts @@ -8,6 +8,8 @@ export { applicationMarkRunning } from "./applicationMarkRunning.js"; export { applicationMove } from "./applicationMove.js"; export { applicationOne } from "./applicationOne.js"; export { applicationReadAppMonitoring } from "./applicationReadAppMonitoring.js"; +export { applicationReadContainerLogs } from "./applicationReadContainerLogs.js"; +export { applicationReadDeploymentLogs } from "./applicationReadDeploymentLogs.js"; export { applicationReadTraefikConfig } from "./applicationReadTraefikConfig.js"; export { applicationRedeploy } from "./applicationRedeploy.js"; export { applicationRefreshToken } from "./applicationRefreshToken.js"; diff --git a/src/utils/websocketLogs.ts b/src/utils/websocketLogs.ts new file mode 100644 index 0000000..623baa5 --- /dev/null +++ b/src/utils/websocketLogs.ts @@ -0,0 +1,140 @@ +import { URL } from "node:url"; +import WebSocket, { RawData } from "ws"; +import { getClientConfig } from "./clientConfig.js"; +import { createLogger } from "./logger.js"; + +const logger = createLogger("WebSocketLogs"); + +export interface WebSocketLogOptions { + path: "/docker-container-logs" | "/listen-deployment"; + query: Record; + timeoutMs?: number; + maxChars?: number; +} + +export interface WebSocketLogResult { + logs: string; + timedOut: boolean; + truncated: boolean; + messageCount: number; + closeCode?: number; + closeReason?: string; +} + +function getWebSocketBaseUrl(): string { + const config = getClientConfig(); + const base = new URL(config.dokployUrl); + + if (base.pathname.endsWith("/api")) { + base.pathname = base.pathname.slice(0, -4) || "/"; + } + + base.pathname = base.pathname.replace(/\/+$/, "") || "/"; + base.search = ""; + base.hash = ""; + base.protocol = base.protocol === "https:" ? "wss:" : "ws:"; + + return base.toString().replace(/\/$/, ""); +} + +export async function collectWebSocketLogs( + options: WebSocketLogOptions +): Promise { + const config = getClientConfig(); + const timeoutMs = options.timeoutMs ?? 5000; + const maxChars = options.maxChars ?? 30000; + + const queryParams = new URLSearchParams(); + for (const [key, value] of Object.entries(options.query)) { + if (value !== undefined && value !== "") { + queryParams.set(key, value); + } + } + + const baseUrl = getWebSocketBaseUrl(); + const wsUrl = `${baseUrl}${options.path}?${queryParams.toString()}`; + + return await new Promise((resolve, reject) => { + let logs = ""; + let truncated = false; + let timedOut = false; + let messageCount = 0; + let settled = false; + let closeCode: number | undefined; + let closeReason: string | undefined; + + const finalizeResolve = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + logs: logs.trim(), + timedOut, + truncated, + messageCount, + ...(closeCode !== undefined ? { closeCode } : {}), + ...(closeReason !== undefined ? { closeReason } : {}), + }); + }; + + const finalizeReject = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }; + + logger.info("Opening logs websocket", { + path: options.path, + timeoutMs, + maxChars, + hasApiKey: !!config.authToken, + queryKeys: Object.keys(options.query), + }); + + const ws = new WebSocket(wsUrl, { + ...(config.authToken + ? { + headers: { + "x-api-key": config.authToken, + }, + } + : {}), + handshakeTimeout: Math.min(timeoutMs, 15000), + }); + + const timer = setTimeout(() => { + timedOut = true; + ws.close(1000, "timeout reached"); + }, timeoutMs); + + ws.on("message", (message: RawData) => { + messageCount += 1; + const chunk = message.toString(); + + if (logs.length + chunk.length > maxChars) { + const remaining = Math.max(0, maxChars - logs.length); + logs += chunk.slice(0, remaining); + truncated = true; + ws.close(1000, "max chars reached"); + return; + } + + logs += chunk; + }); + + ws.on("close", (code, reasonBuffer) => { + closeCode = code; + closeReason = reasonBuffer.toString(); + finalizeResolve(); + }); + + ws.on("error", (error: Error) => { + logger.error("WebSocket logs collection failed", { + path: options.path, + error: error.message, + }); + finalizeReject(error); + }); + }); +}