diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index df4e0d64cf..187caffed7 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -304,23 +304,6 @@ jobs: max_attempts: 3 command: cd TestServer && npm install && npm run compile && npm run dep-check - compile-mcp: - runs-on: ubuntu-latest - env: - CI_PIPELINE_ID: ${{github.run_number}} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: latest - - run: cd Common && npm install - - name: Compile MCP - uses: nick-fields/retry@v3 - with: - timeout_minutes: 30 - max_attempts: 3 - command: cd MCP && npm update @oneuptime/common && npm install && npm run compile && npm run dep-check - compile-mobile-app: runs-on: ubuntu-latest env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bbe1c8fbc..f38607f6bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,73 +148,6 @@ jobs: git commit -m "Helm Chart Release ${{needs.read-version.outputs.major_minor}}" git push origin master - mcp-docker-image-deploy: - needs: [generate-build-number, read-version] - runs-on: ubuntu-latest - env: - QEMU_CPU: max - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - name: Docker Meta - id: meta - uses: docker/metadata-action@v4 - with: - images: | - oneuptime/mcp - ghcr.io/oneuptime/mcp - tags: | - type=raw,value=release,enable=true - type=semver,value=${{needs.read-version.outputs.major_minor}},pattern={{version}},enable=true - - - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - uses: actions/setup-node@v4 - with: - node-version: latest - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - image: tonistiigi/binfmt:qemu-v10.0.4 - - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Generate Dockerfile from Dockerfile.tpl - run: npm run prerun - - # Build and deploy mcp. - - - name: Login to Docker Hub - run: | - echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin - - - name: Login to GitHub Container Registry - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin - - - name: Build and push - run: | - bash ./Scripts/GHA/build_docker_images.sh \ - --image mcp \ - --version "${{needs.read-version.outputs.major_minor}}" \ - --dockerfile ./MCP/Dockerfile \ - --context . \ - --platforms linux/amd64,linux/arm64 \ - --git-sha "${{ github.sha }}" - nginx-docker-image-deploy: needs: [generate-build-number, read-version] runs-on: ubuntu-latest @@ -1546,7 +1479,6 @@ jobs: needs: - read-version - generate-build-number - - mcp-docker-image-deploy - nginx-docker-image-deploy - e2e-docker-image-deploy - isolated-vm-docker-image-deploy @@ -1637,7 +1569,7 @@ jobs: test-e2e-release-saas: runs-on: ubuntu-latest - needs: [telemetry-docker-image-deploy, mcp-docker-image-deploy, docs-docker-image-deploy, workflow-docker-image-deploy, accounts-docker-image-deploy, ai-agent-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-deploy] + needs: [telemetry-docker-image-deploy, docs-docker-image-deploy, workflow-docker-image-deploy, accounts-docker-image-deploy, ai-agent-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-deploy] env: CI_PIPELINE_ID: ${{github.run_number}} steps: @@ -1768,7 +1700,7 @@ jobs: test-e2e-release-self-hosted: runs-on: ubuntu-latest # After all the jobs runs - needs: [telemetry-docker-image-deploy, mcp-docker-image-deploy, docs-docker-image-deploy, workflow-docker-image-deploy, accounts-docker-image-deploy, ai-agent-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-deploy] + needs: [telemetry-docker-image-deploy, docs-docker-image-deploy, workflow-docker-image-deploy, accounts-docker-image-deploy, ai-agent-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-deploy] env: CI_PIPELINE_ID: ${{github.run_number}} steps: diff --git a/.github/workflows/test-release.yaml b/.github/workflows/test-release.yaml index 73f153c490..1b74fc20b1 100644 --- a/.github/workflows/test-release.yaml +++ b/.github/workflows/test-release.yaml @@ -86,76 +86,6 @@ jobs: echo "patch=${target_patch}" >> "$GITHUB_OUTPUT" echo "Using version base: ${new_version}" - mcp-docker-image-deploy: - needs: [read-version, generate-build-number] - runs-on: ubuntu-latest - env: - QEMU_CPU: max - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - name: Docker Meta - id: meta - uses: docker/metadata-action@v4 - with: - images: | - oneuptime/mcp - ghcr.io/oneuptime/mcp - tags: | - type=raw,value=test,enable=true - type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true - - - - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - - - uses: actions/setup-node@v4 - with: - node-version: latest - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - image: tonistiigi/binfmt:qemu-v10.0.4 - - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Generate Dockerfile from Dockerfile.tpl - run: npm run prerun - - # Build and deploy mcp. - - - name: Login to Docker Hub - run: | - echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin - - - name: Login to GitHub Container Registry - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin - - - name: Build and push - run: | - bash ./Scripts/GHA/build_docker_images.sh \ - --image mcp \ - --version "${{needs.read-version.outputs.major_minor}}-test" \ - --dockerfile ./MCP/Dockerfile \ - --context . \ - --platforms linux/amd64,linux/arm64 \ - --git-sha "${{ github.sha }}" \ - --extra-tags test \ - --extra-enterprise-tags enterprise-test - nginx-docker-image-deploy: needs: [read-version, generate-build-number] runs-on: ubuntu-latest @@ -1442,7 +1372,7 @@ jobs: test-helm-chart: runs-on: ubuntu-latest - needs: [infrastructure-agent-deploy, mcp-docker-image-deploy, publish-terraform-provider, telemetry-docker-image-deploy, docs-docker-image-deploy, worker-docker-image-deploy, workflow-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-docker-image-deploy, dashboard-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, accounts-docker-image-deploy, ai-agent-docker-image-deploy, otel-collector-docker-image-deploy, status-page-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy] + needs: [infrastructure-agent-deploy, publish-terraform-provider, telemetry-docker-image-deploy, docs-docker-image-deploy, worker-docker-image-deploy, workflow-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-docker-image-deploy, dashboard-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, accounts-docker-image-deploy, ai-agent-docker-image-deploy, otel-collector-docker-image-deploy, status-page-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy] env: CI_PIPELINE_ID: ${{github.run_number}} steps: diff --git a/.github/workflows/test.mcp.yaml b/.github/workflows/test.mcp.yaml deleted file mode 100644 index 764423297c..0000000000 --- a/.github/workflows/test.mcp.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: MCP Server Test - -on: - pull_request: - push: - branches-ignore: - - 'hotfix-*' # excludes hotfix branches - - 'release' - -jobs: - test: - runs-on: ubuntu-latest - env: - CI_PIPELINE_ID: ${{github.run_number}} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: latest - - run: cd Common && npm install - - run: cd MCP && npm install && npm run test diff --git a/App/FeatureSet/MCP/Config/ServerConfig.ts b/App/FeatureSet/MCP/Config/ServerConfig.ts new file mode 100644 index 0000000000..b2b5d868af --- /dev/null +++ b/App/FeatureSet/MCP/Config/ServerConfig.ts @@ -0,0 +1,30 @@ +/** + * Server Configuration + * Centralized configuration for the MCP server + */ + +import { Host, HttpProtocol } from "Common/Server/EnvironmentConfig"; + +// Application name used across the server +export const APP_NAME: string = "mcp"; + +// MCP Server information +export const MCP_SERVER_NAME: string = "oneuptime-mcp"; +export const MCP_SERVER_VERSION: string = "1.0.0"; + +// Route prefixes for the MCP server (only /mcp since App owns root) +export const ROUTE_PREFIXES: string[] = [`/${APP_NAME}`]; + +// API URL configuration +export function getApiUrl(): string { + return Host ? `${HttpProtocol}${Host}` : "https://oneuptime.com"; +} + +// Session header name +export const SESSION_HEADER: string = "mcp-session-id"; + +// API key header names +export const API_KEY_HEADERS: string[] = ["x-api-key", "authorization"]; + +// Response formatting limits +export const LIST_PREVIEW_LIMIT: number = 5; diff --git a/App/FeatureSet/MCP/Handlers/RouteHandler.ts b/App/FeatureSet/MCP/Handlers/RouteHandler.ts new file mode 100644 index 0000000000..094c1c55ec --- /dev/null +++ b/App/FeatureSet/MCP/Handlers/RouteHandler.ts @@ -0,0 +1,232 @@ +/** + * Route Handler + * Sets up Express routes for the MCP server + */ + +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { randomUUID } from "node:crypto"; +import { + ExpressApplication, + ExpressRequest, + ExpressResponse, + NextFunction, + ExpressJson, +} from "Common/Server/Utils/Express"; +import { getMCPServer, McpServer } from "../Server/MCPServer"; +import SessionManager, { SessionData } from "../Server/SessionManager"; +import { McpToolInfo } from "../Types/McpTypes"; +import { + ROUTE_PREFIXES, + SESSION_HEADER, + API_KEY_HEADERS, +} from "../Config/ServerConfig"; +import logger from "Common/Server/Utils/Logger"; + +// Type for MCP handler function +type McpHandlerFunction = ( + req: ExpressRequest, + res: ExpressResponse, + next: NextFunction, +) => Promise; + +/** + * Extract API key from request headers + */ +export function extractApiKey(req: ExpressRequest): string | undefined { + for (const header of API_KEY_HEADERS) { + const value: string | undefined = req.headers[header] as string | undefined; + if (value) { + // Handle Bearer token format + if (header === "authorization" && value.startsWith("Bearer ")) { + return value.replace("Bearer ", ""); + } + return value; + } + } + return undefined; +} + +/** + * Setup all MCP-specific routes on the Express app + */ +export function setupMCPRoutes( + app: ExpressApplication, + tools: McpToolInfo[], +): void { + ROUTE_PREFIXES.forEach((prefix: string) => { + setupRoutesForPrefix(app, prefix, tools); + }); + + logger.info( + `MCP routes setup complete for prefixes: ${ROUTE_PREFIXES.join(", ")}`, + ); +} + +/** + * Setup routes for a specific prefix + */ +function setupRoutesForPrefix( + app: ExpressApplication, + prefix: string, + tools: McpToolInfo[], +): void { + const mcpEndpoint: string = `${prefix}/mcp`; + const mcpHandler: McpHandlerFunction = createMCPHandler(); + + // MCP endpoint for all methods (GET for SSE, POST for requests, DELETE for cleanup) + app.get(mcpEndpoint, mcpHandler); + app.post(mcpEndpoint, ExpressJson(), mcpHandler); + app.delete(mcpEndpoint, mcpHandler); + + // OPTIONS handler for CORS preflight requests + app.options(mcpEndpoint, (_req: ExpressRequest, res: ExpressResponse) => { + res.status(200).end(); + }); + + // List tools endpoint (REST API) + setupToolsEndpoint(app, prefix, tools); + + // Health check endpoint + setupHealthEndpoint(app, prefix, tools); +} + +/** + * Create the main MCP request handler + */ +function createMCPHandler(): McpHandlerFunction { + return async ( + req: ExpressRequest, + res: ExpressResponse, + next: NextFunction, + ): Promise => { + try { + // Extract API key (optional - public tools work without it) + const apiKey: string | undefined = extractApiKey(req); + + // Set the current API key for tool calls (may be undefined for public tools) + SessionManager.setCurrentApiKey(apiKey || ""); + + // Check for existing session + const sessionId: string | undefined = req.headers[ + SESSION_HEADER + ] as string; + + if (sessionId && SessionManager.hasSession(sessionId)) { + await handleExistingSession(req, res, sessionId, apiKey || ""); + return; + } + + // Create new session for new connections + await handleNewSession(req, res, apiKey || ""); + } catch (error) { + next(error); + } + }; +} + +/** + * Handle request for an existing session + */ +async function handleExistingSession( + req: ExpressRequest, + res: ExpressResponse, + sessionId: string, + apiKey: string, +): Promise { + const sessionData: SessionData | undefined = + SessionManager.getSession(sessionId); + + if (!sessionData) { + return; + } + + // Update API key in case it changed + sessionData.apiKey = apiKey; + await sessionData.transport.handleRequest(req, res, req.body); +} + +/** + * Handle request for a new session (initialization) + */ +async function handleNewSession( + req: ExpressRequest, + res: ExpressResponse, + apiKey: string, +): Promise { + const mcpServer: McpServer = getMCPServer(); + + const transport: StreamableHTTPServerTransport = + new StreamableHTTPServerTransport({ + sessionIdGenerator: (): string => { + return randomUUID(); + }, + onsessioninitialized: (newSessionId: string): void => { + // Store the transport with the new session ID and API key + SessionManager.setSession(newSessionId, { transport, apiKey }); + logger.info(`New MCP session initialized: ${newSessionId}`); + }, + }); + + // Handle transport close + transport.onclose = (): void => { + const transportSessionId: string | undefined = transport.sessionId; + if (transportSessionId) { + logger.info(`MCP session closed: ${transportSessionId}`); + SessionManager.removeSession(transportSessionId); + } + }; + + // Handle transport errors + transport.onerror = (error: Error): void => { + logger.error(`MCP transport error: ${error.message}`); + }; + + // Connect the MCP server to this transport + await mcpServer.connect(transport as Parameters[0]); + + // Handle the request + await transport.handleRequest(req, res, req.body); +} + +/** + * Setup the tools listing endpoint + */ +function setupToolsEndpoint( + app: ExpressApplication, + prefix: string, + tools: McpToolInfo[], +): void { + const endpoint: string = `${prefix}/tools`; + + app.get(endpoint, (_req: ExpressRequest, res: ExpressResponse) => { + const toolsList: Array<{ name: string; description: string }> = tools.map( + (tool: McpToolInfo) => { + return { + name: tool.name, + description: tool.description, + }; + }, + ); + res.json({ tools: toolsList, count: toolsList.length }); + }); +} + +/** + * Setup the health check endpoint + */ +function setupHealthEndpoint( + app: ExpressApplication, + prefix: string, + tools: McpToolInfo[], +): void { + const endpoint: string = `${prefix}/health`; + + app.get(endpoint, (_req: ExpressRequest, res: ExpressResponse) => { + res.json({ + status: "healthy", + service: "oneuptime-mcp", + tools: tools.length, + activeSessions: SessionManager.getSessionCount(), + }); + }); +} diff --git a/App/FeatureSet/MCP/Handlers/ToolHandler.ts b/App/FeatureSet/MCP/Handlers/ToolHandler.ts new file mode 100644 index 0000000000..b4b3355064 --- /dev/null +++ b/App/FeatureSet/MCP/Handlers/ToolHandler.ts @@ -0,0 +1,376 @@ +/** + * Tool Handler + * Handles MCP tool execution and response formatting + */ + +import { + CallToolRequestSchema, + CallToolRequest, + ErrorCode, + ListToolsRequestSchema, + McpError, +} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + McpToolInfo, + OneUptimeToolCallArgs, + JSONSchema, +} from "../Types/McpTypes"; +import OneUptimeOperation from "../Types/OneUptimeOperation"; +import OneUptimeApiService from "../Services/OneUptimeApiService"; +import SessionManager from "../Server/SessionManager"; +import { LIST_PREVIEW_LIMIT } from "../Config/ServerConfig"; +import { isHelperTool, handleHelperTool } from "../Tools/HelperTools"; +import { + isPublicStatusPageTool, + handlePublicStatusPageTool, +} from "../Tools/PublicStatusPageTools"; +import logger from "Common/Server/Utils/Logger"; + +/** + * Register tool handlers on the MCP server + */ +export function registerToolHandlers( + mcpServer: McpServer, + tools: McpToolInfo[], +): void { + // Register list tools handler + mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => { + return handleListTools(tools); + }); + + // Register call tool handler + mcpServer.server.setRequestHandler( + CallToolRequestSchema, + async (request: CallToolRequest) => { + return handleCallTool(request, tools); + }, + ); + + logger.info(`Registered handlers for ${tools.length} tools`); +} + +/** + * Handle list tools request + */ +function handleListTools(tools: McpToolInfo[]): { + tools: Array<{ name: string; description: string; inputSchema: JSONSchema }>; +} { + const mcpTools: Array<{ + name: string; + description: string; + inputSchema: JSONSchema; + }> = tools.map((tool: McpToolInfo) => { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }; + }); + + logger.info(`Listing ${mcpTools.length} available tools`); + return { tools: mcpTools }; +} + +/** + * Handle tool call request + */ +async function handleCallTool( + request: CallToolRequest, + tools: McpToolInfo[], +): Promise<{ content: Array<{ type: string; text: string }> }> { + const { name, arguments: args } = request.params; + + try { + // Check if this is a helper tool (doesn't require API key) + if (isHelperTool(name)) { + logger.info(`Executing helper tool: ${name}`); + const responseText: string = handleHelperTool( + name, + (args || {}) as Record, + tools.filter((t: McpToolInfo) => { + return !isHelperTool(t.name) && !isPublicStatusPageTool(t.name); + }), + ); + return { + content: [ + { + type: "text", + text: responseText, + }, + ], + }; + } + + // Check if this is a public status page tool (doesn't require API key) + if (isPublicStatusPageTool(name)) { + logger.info(`Executing public status page tool: ${name}`); + const responseText: string = await handlePublicStatusPageTool( + name, + (args || {}) as Record, + ); + return { + content: [ + { + type: "text", + text: responseText, + }, + ], + }; + } + + // Find the tool by name + const tool: McpToolInfo | undefined = tools.find((t: McpToolInfo) => { + return t.name === name; + }); + + if (!tool) { + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${name}. Use 'oneuptime_help' to see available tools.`, + ); + } + + logger.info(`Executing tool: ${name} for model: ${tool.modelName}`); + + // Validate API key is available for this session + const apiKey: string = SessionManager.getCurrentApiKey(); + if (!apiKey) { + throw new McpError( + ErrorCode.InvalidRequest, + "API key is required. Please provide x-api-key header in your request. Use 'oneuptime_help' to learn more.", + ); + } + + // Execute the OneUptime operation with the session's API key + const result: unknown = await OneUptimeApiService.executeOperation( + tool.tableName, + tool.operation, + tool.modelType, + tool.apiPath || "", + args as OneUptimeToolCallArgs, + apiKey, + ); + + // Format the response + const responseText: string = formatToolResponse( + tool, + result, + args as OneUptimeToolCallArgs, + ); + + return { + content: [ + { + type: "text", + text: responseText, + }, + ], + }; + } catch (error) { + logger.error(`Error executing tool ${name}: ${error}`); + + if (error instanceof McpError) { + throw error; + } + + throw new McpError( + ErrorCode.InternalError, + `Failed to execute ${name}: ${error}. Use 'oneuptime_help' for guidance.`, + ); + } +} + +/** + * Format tool response based on operation type + */ +export function formatToolResponse( + tool: McpToolInfo, + result: unknown, + args: OneUptimeToolCallArgs, +): string { + const operation: OneUptimeOperation = tool.operation; + const modelName: string = tool.singularName; + const pluralName: string = tool.pluralName; + + switch (operation) { + case OneUptimeOperation.Create: + return formatCreateResponse(modelName, result); + + case OneUptimeOperation.Read: + return formatReadResponse(modelName, result, args.id); + + case OneUptimeOperation.List: + return formatListResponse(modelName, pluralName, result); + + case OneUptimeOperation.Update: + return formatUpdateResponse(modelName, result, args.id); + + case OneUptimeOperation.Delete: + return formatDeleteResponse(modelName, args.id); + + case OneUptimeOperation.Count: + return formatCountResponse(pluralName, result); + + default: + return `Operation ${operation} completed successfully: ${JSON.stringify(result, null, 2)}`; + } +} + +function formatCreateResponse(modelName: string, result: unknown): string { + const response: Record = { + success: true, + operation: "create", + resourceType: modelName, + message: `Successfully created ${modelName}`, + data: result, + }; + return JSON.stringify(response, null, 2); +} + +function formatReadResponse( + modelName: string, + result: unknown, + id: string | undefined, +): string { + if (result) { + const response: Record = { + success: true, + operation: "read", + resourceType: modelName, + resourceId: id, + data: result, + }; + return JSON.stringify(response, null, 2); + } + const response: Record = { + success: false, + operation: "read", + resourceType: modelName, + resourceId: id, + error: `${modelName} not found with ID: ${id}`, + suggestion: `Use list_${modelName.toLowerCase().replace(/\s+/g, "_")}s to find valid IDs`, + }; + return JSON.stringify(response, null, 2); +} + +function formatListResponse( + modelName: string, + pluralName: string, + result: unknown, +): string { + const items: Array = Array.isArray(result) + ? result + : (result as { data?: Array })?.data || []; + const count: number = items.length; + + const response: Record = { + success: true, + operation: "list", + resourceType: pluralName, + totalReturned: count, + hasMore: count >= LIST_PREVIEW_LIMIT, + message: + count === 0 + ? `No ${pluralName} found matching the criteria` + : `Found ${count} ${count === 1 ? modelName : pluralName}`, + data: items.slice(0, LIST_PREVIEW_LIMIT), + }; + + if (count > LIST_PREVIEW_LIMIT) { + response["note"] = + `Showing first ${LIST_PREVIEW_LIMIT} results. Use 'skip' parameter to paginate.`; + } + + return JSON.stringify(response, null, 2); +} + +function formatUpdateResponse( + modelName: string, + result: unknown, + id: string | undefined, +): string { + const response: Record = { + success: true, + operation: "update", + resourceType: modelName, + resourceId: id, + message: `Successfully updated ${modelName}`, + data: result, + }; + return JSON.stringify(response, null, 2); +} + +function formatDeleteResponse( + modelName: string, + id: string | undefined, +): string { + const response: Record = { + success: true, + operation: "delete", + resourceType: modelName, + resourceId: id, + message: `Successfully deleted ${modelName} (ID: ${id})`, + }; + return JSON.stringify(response, null, 2); +} + +function formatCountResponse(pluralName: string, result: unknown): string { + let totalCount: number = 0; + + if (result !== null && result !== undefined) { + if (typeof result === "number") { + totalCount = result; + } else if (typeof result === "object") { + const resultObj: Record = result as Record< + string, + unknown + >; + + // Handle { count: number } format + if ("count" in resultObj) { + const countValue: unknown = resultObj["count"]; + if (typeof countValue === "number") { + totalCount = countValue; + } else if (typeof countValue === "object" && countValue !== null) { + // Handle PositiveNumber or other objects with value/toNumber + const countObj: Record = countValue as Record< + string, + unknown + >; + if (typeof countObj["value"] === "number") { + totalCount = countObj["value"]; + } else if ( + typeof (countObj as { toNumber?: () => number }).toNumber === + "function" + ) { + totalCount = (countObj as { toNumber: () => number }).toNumber(); + } + } + } + // Handle { data: { count: number } } format + else if ( + "data" in resultObj && + typeof resultObj["data"] === "object" && + resultObj["data"] !== null + ) { + const dataObj: Record = resultObj["data"] as Record< + string, + unknown + >; + if ("count" in dataObj && typeof dataObj["count"] === "number") { + totalCount = dataObj["count"]; + } + } + } + } + + const response: Record = { + success: true, + operation: "count", + resourceType: pluralName, + count: totalCount, + message: `Total count of ${pluralName}: ${totalCount}`, + }; + return JSON.stringify(response, null, 2); +} diff --git a/App/FeatureSet/MCP/Index.ts b/App/FeatureSet/MCP/Index.ts new file mode 100644 index 0000000000..973cfb2c27 --- /dev/null +++ b/App/FeatureSet/MCP/Index.ts @@ -0,0 +1,51 @@ +/** + * MCP FeatureSet + * Integrates the Model Context Protocol server into the App service + */ + +import FeatureSet from "Common/Server/Types/FeatureSet"; +import Express, { ExpressApplication } from "Common/Server/Utils/Express"; +import logger from "Common/Server/Utils/Logger"; + +import { getApiUrl } from "./Config/ServerConfig"; +import { initializeMCPServer, getMCPServer } from "./Server/MCPServer"; +import { registerToolHandlers } from "./Handlers/ToolHandler"; +import { setupMCPRoutes } from "./Handlers/RouteHandler"; +import { generateAllTools } from "./Tools/ToolGenerator"; +import OneUptimeApiService, { + OneUptimeApiConfig, +} from "./Services/OneUptimeApiService"; +import { McpToolInfo } from "./Types/McpTypes"; + +const MCPFeatureSet: FeatureSet = { + init: async (): Promise => { + const app: ExpressApplication = Express.getExpressApp(); + + // Initialize OneUptime API Service + const apiUrl: string = getApiUrl(); + const config: OneUptimeApiConfig = { + url: apiUrl, + }; + OneUptimeApiService.initialize(config); + logger.info( + `MCP: OneUptime API Service initialized with: ${apiUrl}`, + ); + + // Initialize MCP server + initializeMCPServer(); + + // Generate tools + const tools: McpToolInfo[] = generateAllTools(); + logger.info(`MCP: Generated ${tools.length} tools`); + + // Register tool handlers + registerToolHandlers(getMCPServer(), tools); + + // Setup MCP-specific routes + setupMCPRoutes(app, tools); + + logger.info(`MCP FeatureSet initialized successfully`); + }, +}; + +export default MCPFeatureSet; diff --git a/App/FeatureSet/MCP/Server/MCPServer.ts b/App/FeatureSet/MCP/Server/MCPServer.ts new file mode 100644 index 0000000000..96f3a52d54 --- /dev/null +++ b/App/FeatureSet/MCP/Server/MCPServer.ts @@ -0,0 +1,66 @@ +/** + * MCP Server + * Handles MCP server initialization and configuration + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../Config/ServerConfig"; +import logger from "Common/Server/Utils/Logger"; + +// Singleton MCP server instance +let mcpServerInstance: McpServer | null = null; + +/** + * Initialize and return the MCP server instance + */ +export function initializeMCPServer(): McpServer { + if (mcpServerInstance) { + return mcpServerInstance; + } + + mcpServerInstance = new McpServer( + { + name: MCP_SERVER_NAME, + version: MCP_SERVER_VERSION, + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + logger.info( + `MCP Server initialized: ${MCP_SERVER_NAME} v${MCP_SERVER_VERSION}`, + ); + return mcpServerInstance; +} + +/** + * Get the MCP server instance + * @throws Error if server not initialized + */ +export function getMCPServer(): McpServer { + if (!mcpServerInstance) { + throw new Error( + "MCP Server not initialized. Call initializeMCPServer() first.", + ); + } + return mcpServerInstance; +} + +/** + * Check if MCP server is initialized + */ +export function isMCPServerInitialized(): boolean { + return mcpServerInstance !== null; +} + +/** + * Reset MCP server (useful for testing) + */ +export function resetMCPServer(): void { + mcpServerInstance = null; +} + +export { McpServer }; diff --git a/App/FeatureSet/MCP/Server/SessionManager.ts b/App/FeatureSet/MCP/Server/SessionManager.ts new file mode 100644 index 0000000000..2b1fa3cca2 --- /dev/null +++ b/App/FeatureSet/MCP/Server/SessionManager.ts @@ -0,0 +1,113 @@ +/** + * Session Manager + * Manages MCP sessions and their associated data + */ + +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import logger from "Common/Server/Utils/Logger"; + +// Session data interface +export interface SessionData { + transport: StreamableHTTPServerTransport; + apiKey: string; +} + +/** + * SessionManager handles the lifecycle of MCP sessions + */ +export default class SessionManager { + private static sessions: Map = new Map(); + private static currentSessionApiKey: string = ""; + + /** + * Get all active sessions + */ + public static getSessions(): Map { + return this.sessions; + } + + /** + * Check if a session exists + */ + public static hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + /** + * Get a session by ID + */ + public static getSession(sessionId: string): SessionData | undefined { + return this.sessions.get(sessionId); + } + + /** + * Create or update a session + */ + public static setSession(sessionId: string, data: SessionData): void { + this.sessions.set(sessionId, data); + logger.info(`MCP session stored: ${sessionId}`); + } + + /** + * Update the API key for an existing session + */ + public static updateSessionApiKey( + sessionId: string, + apiKey: string, + ): boolean { + const session: SessionData | undefined = this.sessions.get(sessionId); + if (session) { + session.apiKey = apiKey; + return true; + } + return false; + } + + /** + * Remove a session + */ + public static removeSession(sessionId: string): boolean { + const deleted: boolean = this.sessions.delete(sessionId); + if (deleted) { + logger.info(`MCP session removed: ${sessionId}`); + } + return deleted; + } + + /** + * Get the current session API key (used during request processing) + */ + public static getCurrentApiKey(): string { + return this.currentSessionApiKey; + } + + /** + * Set the current session API key (called at the start of each request) + */ + public static setCurrentApiKey(apiKey: string): void { + this.currentSessionApiKey = apiKey; + } + + /** + * Clear the current session API key + */ + public static clearCurrentApiKey(): void { + this.currentSessionApiKey = ""; + } + + /** + * Get the count of active sessions + */ + public static getSessionCount(): number { + return this.sessions.size; + } + + /** + * Clear all sessions (useful for cleanup) + */ + public static clearAllSessions(): void { + this.sessions.clear(); + this.currentSessionApiKey = ""; + logger.info("All MCP sessions cleared"); + } +} diff --git a/App/FeatureSet/MCP/Services/OneUptimeApiService.ts b/App/FeatureSet/MCP/Services/OneUptimeApiService.ts new file mode 100644 index 0000000000..ebc35e3a55 --- /dev/null +++ b/App/FeatureSet/MCP/Services/OneUptimeApiService.ts @@ -0,0 +1,378 @@ +/** + * OneUptime API Service + * Handles communication with the OneUptime API + */ + +import OneUptimeOperation from "../Types/OneUptimeOperation"; +import ModelType from "../Types/ModelType"; +import { OneUptimeToolCallArgs } from "../Types/McpTypes"; +import { generateAllFieldsSelect } from "./SelectFieldGenerator"; +import MCPLogger from "../Utils/MCPLogger"; +import API from "Common/Utils/API"; +import URL from "Common/Types/API/URL"; +import Route from "Common/Types/API/Route"; +import Headers from "Common/Types/API/Headers"; +import HTTPResponse from "Common/Types/API/HTTPResponse"; +import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse"; +import { JSONObject, JSONValue } from "Common/Types/JSON"; +import Protocol from "Common/Types/API/Protocol"; +import Hostname from "Common/Types/API/Hostname"; + +export interface OneUptimeApiConfig { + url: string; + apiKey?: string; +} + +export default class OneUptimeApiService { + private static api: API; + + /** + * Initialize the API service with configuration + */ + public static initialize(config: OneUptimeApiConfig): void { + try { + const url: URL = URL.fromString(config.url); + const protocol: Protocol = url.protocol; + const hostname: Hostname = url.hostname; + + this.api = new API(protocol, hostname, new Route("/")); + } catch (error) { + throw new Error(`Invalid URL format: ${config.url}. Error: ${error}`); + } + + MCPLogger.info(`OneUptime API Service initialized with: ${config.url}`); + } + + /** + * Execute a OneUptime operation + */ + public static async executeOperation( + tableName: string, + operation: OneUptimeOperation, + modelType: ModelType, + apiPath: string, + args: OneUptimeToolCallArgs, + apiKey: string, + ): Promise { + this.validateInitialization(); + this.validateApiKey(apiKey); + this.validateOperationArgs(operation, args); + + const route: Route = this.buildApiRoute(apiPath, operation, args.id); + const headers: Headers = this.buildHeaders(apiKey); + const data: JSONObject | undefined = this.buildRequestData( + operation, + args, + tableName, + modelType, + ); + + MCPLogger.info( + `Executing ${operation} operation for ${tableName} at ${route.toString()}`, + ); + + try { + const response: JSONValue = await this.makeApiRequest( + operation, + route, + headers, + data, + ); + MCPLogger.info( + `Successfully executed ${operation} operation for ${tableName}`, + ); + return response; + } catch (error) { + MCPLogger.error( + `Error executing ${operation} operation for ${tableName}: ${error}`, + ); + throw error; + } + } + + /** + * Make the actual API request + */ + private static async makeApiRequest( + operation: OneUptimeOperation, + route: Route, + headers: Headers, + data: JSONObject | undefined, + ): Promise { + const url: URL = new URL(this.api.protocol, this.api.hostname, route); + const baseOptions: { url: URL; headers: Headers } = { url, headers }; + + let response: HTTPResponse | HTTPErrorResponse; + + switch (operation) { + case OneUptimeOperation.Create: + case OneUptimeOperation.Count: + case OneUptimeOperation.List: + case OneUptimeOperation.Read: + response = await API.post( + data ? { ...baseOptions, data } : baseOptions, + ); + break; + case OneUptimeOperation.Update: + response = await API.put(data ? { ...baseOptions, data } : baseOptions); + break; + case OneUptimeOperation.Delete: + response = await API.delete( + data ? { ...baseOptions, data } : baseOptions, + ); + break; + default: + throw new Error(`Unsupported operation: ${operation}`); + } + + if (response instanceof HTTPErrorResponse) { + throw new Error( + `API request failed: ${response.statusCode} - ${response.message}`, + ); + } + + return response.data; + } + + /** + * Build the API route for an operation + */ + private static buildApiRoute( + apiPath: string, + operation: OneUptimeOperation, + id?: string, + ): Route { + let fullPath: string = `/api${apiPath}`; + + switch (operation) { + case OneUptimeOperation.Read: + if (id) { + fullPath = `/api${apiPath}/${id}/get-item`; + } + break; + case OneUptimeOperation.Update: + case OneUptimeOperation.Delete: + if (id) { + fullPath = `/api${apiPath}/${id}/`; + } + break; + case OneUptimeOperation.Count: + fullPath = `/api${apiPath}/count`; + break; + case OneUptimeOperation.List: + fullPath = `/api${apiPath}/get-list`; + break; + case OneUptimeOperation.Create: + default: + fullPath = `/api${apiPath}`; + break; + } + + return new Route(fullPath); + } + + /** + * Build request data based on operation type + */ + private static buildRequestData( + operation: OneUptimeOperation, + args: OneUptimeToolCallArgs, + tableName: string, + modelType: ModelType, + ): JSONObject | undefined { + MCPLogger.info( + `Preparing request data for operation: ${operation}, tableName: ${tableName}`, + ); + + switch (operation) { + case OneUptimeOperation.Create: + return this.buildCreateData(args); + + case OneUptimeOperation.Update: + return this.buildUpdateData(args); + + case OneUptimeOperation.List: + case OneUptimeOperation.Count: + return this.buildQueryData(args, tableName, modelType); + + case OneUptimeOperation.Read: + return this.buildReadData(args, tableName, modelType); + + case OneUptimeOperation.Delete: + default: + return undefined; + } + } + + private static buildCreateData(args: OneUptimeToolCallArgs): JSONObject { + const createData: JSONObject = {}; + const reservedFields: string[] = [ + "id", + "query", + "select", + "skip", + "limit", + "sort", + ]; + + for (const [key, value] of Object.entries(args)) { + if (!reservedFields.includes(key)) { + createData[key] = value as JSONValue; + } + } + + return { data: createData } as JSONObject; + } + + private static buildUpdateData(args: OneUptimeToolCallArgs): JSONObject { + const updateData: JSONObject = {}; + const reservedFields: string[] = [ + "id", + "query", + "select", + "skip", + "limit", + "sort", + ]; + + for (const [key, value] of Object.entries(args)) { + if (!reservedFields.includes(key)) { + updateData[key] = value as JSONValue; + } + } + + return { data: updateData } as JSONObject; + } + + private static buildQueryData( + args: OneUptimeToolCallArgs, + tableName: string, + modelType: ModelType, + ): JSONObject { + const generatedSelect: JSONObject = + args.select || generateAllFieldsSelect(tableName, modelType); + + const requestData: JSONObject = { + query: args.query || {}, + select: generatedSelect, + skip: args.skip, + limit: args.limit, + sort: args.sort, + } as JSONObject; + + MCPLogger.info(`Request data: ${JSON.stringify(requestData, null, 2)}`); + return requestData; + } + + private static buildReadData( + args: OneUptimeToolCallArgs, + tableName: string, + modelType: ModelType, + ): JSONObject { + const readSelect: JSONObject = + args.select || generateAllFieldsSelect(tableName, modelType); + + const readRequestData: JSONObject = { + select: readSelect, + } as JSONObject; + + MCPLogger.info( + `Request data for Read: ${JSON.stringify(readRequestData, null, 2)}`, + ); + return readRequestData; + } + + /** + * Build headers for API request + */ + private static buildHeaders(apiKey: string): Headers { + return { + "Content-Type": "application/json", + Accept: "application/json", + APIKey: apiKey, + }; + } + + /** + * Validate that the service is initialized + */ + private static validateInitialization(): void { + if (!this.api) { + throw new Error( + "OneUptime API Service not initialized. Please call initialize() first.", + ); + } + } + + /** + * Validate that an API key is provided + */ + private static validateApiKey(apiKey: string): void { + if (!apiKey) { + throw new Error( + "API key is required. Please provide x-api-key header in your request.", + ); + } + } + + /** + * Validate arguments for a specific operation + */ + public static validateOperationArgs( + operation: OneUptimeOperation, + args: OneUptimeToolCallArgs, + ): void { + const reservedFields: string[] = [ + "id", + "query", + "select", + "skip", + "limit", + "sort", + ]; + + switch (operation) { + case OneUptimeOperation.Create: { + const createDataFields: string[] = Object.keys(args).filter( + (key: string) => { + return !reservedFields.includes(key); + }, + ); + if (createDataFields.length === 0) { + throw new Error( + "At least one data field is required for create operation", + ); + } + break; + } + case OneUptimeOperation.Read: + case OneUptimeOperation.Delete: + if (!args.id) { + throw new Error(`ID is required for ${operation} operation`); + } + break; + case OneUptimeOperation.Update: { + if (!args.id) { + throw new Error(`ID is required for ${operation} operation`); + } + const updateDataFields: string[] = Object.keys(args).filter( + (key: string) => { + return !reservedFields.includes(key); + }, + ); + if (updateDataFields.length === 0) { + throw new Error( + "At least one data field is required for update operation", + ); + } + break; + } + case OneUptimeOperation.List: + case OneUptimeOperation.Count: + // No required arguments for list/count operations + break; + default: + throw new Error(`Unknown operation: ${operation}`); + } + } +} diff --git a/App/FeatureSet/MCP/Services/SelectFieldGenerator.ts b/App/FeatureSet/MCP/Services/SelectFieldGenerator.ts new file mode 100644 index 0000000000..08fe770194 --- /dev/null +++ b/App/FeatureSet/MCP/Services/SelectFieldGenerator.ts @@ -0,0 +1,304 @@ +/** + * Select Field Generator + * Generates select field objects for API queries + */ + +import DatabaseModels from "Common/Models/DatabaseModels/Index"; +import AnalyticsModels from "Common/Models/AnalyticsModels/Index"; +import BaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel"; +import AnalyticsBaseModel from "Common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel"; +import { ModelSchema } from "Common/Utils/Schema/ModelSchema"; +import { AnalyticsModelSchema } from "Common/Utils/Schema/AnalyticsModelSchema"; +import { getTableColumns } from "Common/Types/Database/TableColumn"; +import Permission from "Common/Types/Permission"; +import ModelType from "../Types/ModelType"; +import MCPLogger from "../Utils/MCPLogger"; +import { JSONObject } from "Common/Types/JSON"; + +// Type for model constructor +type ModelConstructor = new () => T; + +// Type for model class with table name +interface ModelWithTableName { + tableName: string; + getColumnAccessControlForAllColumns?: () => Record< + string, + ColumnAccessControl + >; +} + +// Type for column access control +interface ColumnAccessControl { + read?: Permission[]; + create?: Permission[]; + update?: Permission[]; +} + +// Type for table columns +type TableColumns = Record; + +// Type for Zod schema shape +interface ZodSchemaWithShape { + _def?: { + shape?: Record | (() => Record); + }; +} + +/** + * Generate a select object that includes all fields from the select schema + */ +export function generateAllFieldsSelect( + tableName: string, + modelType: ModelType, +): JSONObject { + MCPLogger.info( + `Generating select for tableName: ${tableName}, modelType: ${modelType}`, + ); + + try { + const ModelClass: + | ModelConstructor + | ModelConstructor + | null = findModelClass(tableName, modelType); + + if (!ModelClass) { + MCPLogger.warn( + `Model class not found for ${tableName}, using empty select`, + ); + return {}; + } + + MCPLogger.info( + `Found ModelClass: ${(ModelClass as { name: string }).name} for tableName: ${tableName}`, + ); + + // Try to get raw table columns first (most reliable approach) + const selectFromColumns: JSONObject | null = generateSelectFromTableColumns( + ModelClass as ModelConstructor, + tableName, + ); + + if (selectFromColumns && Object.keys(selectFromColumns).length > 0) { + return selectFromColumns; + } + + // Fallback to schema approach if table columns fail + return generateSelectFromSchema(ModelClass, tableName, modelType); + } catch (error) { + MCPLogger.error(`Error generating select for ${tableName}: ${error}`); + return getDefaultSelect(); + } +} + +/** + * Find the model class by table name + */ +function findModelClass( + tableName: string, + modelType: ModelType, +): ModelConstructor | ModelConstructor | null { + if (modelType === ModelType.Database) { + MCPLogger.info(`Searching DatabaseModels for tableName: ${tableName}`); + return ( + (DatabaseModels.find((Model: ModelConstructor): boolean => { + try { + const instance: ModelWithTableName = + new Model() as unknown as ModelWithTableName; + const instanceTableName: string = instance.tableName; + MCPLogger.info( + `Checking model ${Model.name} with tableName: ${instanceTableName}`, + ); + return instanceTableName === tableName; + } catch (error) { + MCPLogger.warn(`Error instantiating model ${Model.name}: ${error}`); + return false; + } + }) as ModelConstructor | undefined) || null + ); + } + + if (modelType === ModelType.Analytics) { + MCPLogger.info(`Searching AnalyticsModels for tableName: ${tableName}`); + return ( + (AnalyticsModels.find( + (Model: ModelConstructor): boolean => { + try { + const instance: ModelWithTableName = + new Model() as unknown as ModelWithTableName; + return instance.tableName === tableName; + } catch (error) { + MCPLogger.warn( + `Error instantiating analytics model ${Model.name}: ${error}`, + ); + return false; + } + }, + ) as ModelConstructor | undefined) || null + ); + } + + return null; +} + +/** + * Generate select object from table columns + */ +function generateSelectFromTableColumns( + ModelClass: ModelConstructor, + tableName: string, +): JSONObject | null { + try { + const modelInstance: ModelWithTableName = + new ModelClass() as unknown as ModelWithTableName; + const tableColumns: TableColumns = getTableColumns( + modelInstance as BaseModel, + ); + const columnNames: string[] = Object.keys(tableColumns); + + MCPLogger.info( + `Raw table columns (${columnNames.length}): ${columnNames.slice(0, 10).join(", ")}`, + ); + + if (columnNames.length === 0) { + return null; + } + + // Get access control information to filter out restricted fields + const accessControlForColumns: Record = + modelInstance.getColumnAccessControlForAllColumns + ? modelInstance.getColumnAccessControlForAllColumns() + : {}; + + const selectObject: JSONObject = {}; + let filteredCount: number = 0; + + for (const columnName of columnNames) { + if (shouldIncludeField(columnName, accessControlForColumns)) { + selectObject[columnName] = true; + } else { + filteredCount++; + MCPLogger.info(`Filtered out restricted field: ${columnName}`); + } + } + + MCPLogger.info( + `Generated select from table columns for ${tableName} with ${Object.keys(selectObject).length} fields (filtered out ${filteredCount} restricted fields)`, + ); + + // Ensure we have at least some basic fields + if (Object.keys(selectObject).length === 0) { + MCPLogger.warn(`All fields were filtered out, adding safe basic fields`); + return getDefaultSelect(); + } + + return selectObject; + } catch (tableColumnError) { + MCPLogger.warn( + `Failed to get table columns for ${tableName}: ${tableColumnError}`, + ); + return null; + } +} + +/** + * Check if a field should be included based on access control + */ +function shouldIncludeField( + columnName: string, + accessControlForColumns: Record, +): boolean { + const accessControl: ColumnAccessControl | undefined = + accessControlForColumns[columnName]; + + /* + * Include the field if: + * 1. No access control defined (open access) + * 2. Has read permissions that are not empty + * 3. Read permissions don't only contain Permission.CurrentUser + */ + return ( + !accessControl || + (accessControl.read !== undefined && + accessControl.read.length > 0 && + !( + accessControl.read.length === 1 && + accessControl.read[0] === Permission.CurrentUser + )) + ); +} + +/** + * Generate select object from model schema (fallback) + */ +function generateSelectFromSchema( + ModelClass: + | ModelConstructor + | ModelConstructor, + tableName: string, + modelType: ModelType, +): JSONObject { + let selectSchema: ZodSchemaWithShape; + + if (modelType === ModelType.Database) { + MCPLogger.info( + `Generating select schema for database model: ${(ModelClass as { name: string }).name}`, + ); + selectSchema = ModelSchema.getSelectModelSchema({ + modelType: ModelClass as ModelConstructor, + }) as ZodSchemaWithShape; + } else { + MCPLogger.info( + `Generating schema for analytics model: ${(ModelClass as { name: string }).name}`, + ); + selectSchema = AnalyticsModelSchema.getModelSchema({ + modelType: ModelClass as ModelConstructor, + }) as ZodSchemaWithShape; + } + + // Extract field names from the schema + const selectObject: JSONObject = {}; + const rawShape: + | Record + | (() => Record) + | undefined = selectSchema._def?.shape; + + // Handle both function and object shapes + const shape: Record | undefined = + typeof rawShape === "function" ? rawShape() : rawShape; + + MCPLogger.info(`Schema shape keys: ${shape ? Object.keys(shape).length : 0}`); + + if (shape) { + const fieldNames: string[] = Object.keys(shape); + MCPLogger.info( + `Available fields: ${fieldNames.slice(0, 10).join(", ")}${fieldNames.length > 10 ? "..." : ""}`, + ); + + for (const fieldName of fieldNames) { + selectObject[fieldName] = true; + } + } + + MCPLogger.info( + `Generated select for ${tableName} with ${Object.keys(selectObject).length} fields`, + ); + + // Force include some basic fields if select is empty + if (Object.keys(selectObject).length === 0) { + MCPLogger.warn(`No fields found, adding basic fields for ${tableName}`); + return getDefaultSelect(); + } + + return selectObject; +} + +/** + * Get default select fields + */ +function getDefaultSelect(): JSONObject { + return { + _id: true, + createdAt: true, + updatedAt: true, + }; +} diff --git a/App/FeatureSet/MCP/Tools/HelperTools.ts b/App/FeatureSet/MCP/Tools/HelperTools.ts new file mode 100644 index 0000000000..e1281dfa99 --- /dev/null +++ b/App/FeatureSet/MCP/Tools/HelperTools.ts @@ -0,0 +1,392 @@ +/** + * Helper Tools + * Provides utility tools for agents to discover and understand OneUptime MCP capabilities + */ + +import { McpToolInfo } from "../Types/McpTypes"; +import OneUptimeOperation from "../Types/OneUptimeOperation"; +import ModelType from "../Types/ModelType"; + +export interface ResourceInfo { + name: string; + singularName: string; + pluralName: string; + description: string; + operations: string[]; +} + +/** + * Generate helper tools for MCP + */ +export function generateHelperTools( + resourceTools: McpToolInfo[], +): McpToolInfo[] { + // Extract unique resources from tools + const resources: Map = new Map(); + + for (const tool of resourceTools) { + if (!resources.has(tool.tableName)) { + resources.set(tool.tableName, { + name: tool.tableName, + singularName: tool.singularName, + pluralName: tool.pluralName, + description: getResourceDescription(tool.singularName), + operations: [], + }); + } + const resource: ResourceInfo | undefined = resources.get(tool.tableName); + if (resource) { + resource.operations.push(tool.operation); + } + } + + const resourceList: ResourceInfo[] = Array.from(resources.values()); + + return [createHelpTool(resourceList), createResourceInfoTool(resourceList)]; +} + +function getResourceDescription(singularName: string): string { + const descriptions: Record = { + Incident: + "Represents service disruptions or issues affecting your systems. Track incident lifecycle from creation to resolution.", + Monitor: + "Defines what to monitor (websites, APIs, servers) and how to check their health and availability.", + Alert: + "Notifications triggered when monitors detect issues. Configures who gets notified and how.", + Project: + "Top-level container for all your monitoring resources. Organizes monitors, incidents, and team members.", + "Status Page": + "Public-facing page showing the status of your services to your customers.", + "Scheduled Maintenance": + "Planned downtime events that inform users about expected service interruptions.", + Team: "Groups of users with shared access to project resources.", + "On-Call Duty Policy": + "Defines escalation rules and schedules for incident response.", + "Incident State": + "Represents the lifecycle states of incidents (e.g., Created, Acknowledged, Resolved).", + "Monitor Status": + "Represents the health states of monitors (e.g., Operational, Degraded, Offline).", + }; + + return ( + descriptions[singularName] || + `Manages ${singularName} resources in OneUptime.` + ); +} + +function createHelpTool(resources: ResourceInfo[]): McpToolInfo { + const resourceSummary: string = resources + .map((r: ResourceInfo) => { + return `- ${r.pluralName}: ${r.description}`; + }) + .join("\n"); + + return { + name: "oneuptime_help", + description: `Get help and guidance for using the OneUptime MCP server. Returns information about available resources and common operations. Use this tool first to understand what you can do with OneUptime. + +AVAILABLE RESOURCES: +${resourceSummary} + +COMMON WORKFLOWS: +1. List incidents: Use list_incidents to see current incidents +2. Create incident: Use create_incident with title and severity +3. Check monitor status: Use list_monitors to see all monitors and their status +4. Count resources: Use count_* tools to get totals without fetching all data`, + inputSchema: { + type: "object", + properties: { + topic: { + type: "string", + description: + "Optional topic to get help on: 'resources', 'incidents', 'monitors', 'alerts', 'workflows', or 'examples'", + enum: [ + "resources", + "incidents", + "monitors", + "alerts", + "workflows", + "examples", + ], + }, + }, + additionalProperties: false, + }, + modelName: "Help", + operation: OneUptimeOperation.Read, + modelType: ModelType.Database, + singularName: "Help", + pluralName: "Help", + tableName: "Help", + apiPath: "", + }; +} + +function createResourceInfoTool(_resources: ResourceInfo[]): McpToolInfo { + return { + name: "oneuptime_list_resources", + description: + "List all available OneUptime resources and their supported operations. Use this to discover what resources you can manage through the MCP server.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + modelName: "ResourceInfo", + operation: OneUptimeOperation.List, + modelType: ModelType.Database, + singularName: "Resource", + pluralName: "Resources", + tableName: "ResourceInfo", + apiPath: "", + }; +} + +/** + * Handle helper tool execution + */ +export function handleHelperTool( + toolName: string, + args: Record, + resourceTools: McpToolInfo[], +): string { + // Extract unique resources from tools + const resources: Map = new Map(); + + for (const tool of resourceTools) { + if (!resources.has(tool.tableName)) { + resources.set(tool.tableName, { + name: tool.tableName, + singularName: tool.singularName, + pluralName: tool.pluralName, + description: getResourceDescription(tool.singularName), + operations: [], + }); + } + const resource: ResourceInfo | undefined = resources.get(tool.tableName); + if (resource) { + resource.operations.push(tool.operation); + } + } + + const resourceList: ResourceInfo[] = Array.from(resources.values()); + + if (toolName === "oneuptime_help") { + return handleHelpTool(args, resourceList); + } else if (toolName === "oneuptime_list_resources") { + return handleListResourcesTool(resourceList); + } + + return JSON.stringify({ error: "Unknown helper tool" }); +} + +function handleHelpTool( + args: Record, + resourceList: ResourceInfo[], +): string { + const topic: string = (args["topic"] as string) || "general"; + + const response: Record = { + success: true, + topic, + data: {} as Record, + }; + + switch (topic) { + case "resources": + (response["data"] as Record)["resources"] = + resourceList.map((r: ResourceInfo) => { + return { + name: r.name, + singularName: r.singularName, + pluralName: r.pluralName, + description: r.description, + availableOperations: r.operations, + }; + }); + (response["data"] as Record)["hint"] = + "Use the specific tool for each operation. For example: list_incidents, create_incident, get_incident, update_incident, delete_incident, count_incidents"; + break; + + case "incidents": + (response["data"] as Record)["description"] = + "Incidents represent service disruptions or issues. They have states (Created, Acknowledged, Resolved) and severities."; + (response["data"] as Record)["commonOperations"] = [ + { + tool: "list_incidents", + description: + "List all incidents, optionally filtered by state or severity", + }, + { + tool: "create_incident", + description: "Create a new incident when an issue is detected", + }, + { + tool: "update_incident", + description: "Update incident state, severity, or add notes", + }, + { + tool: "count_incidents", + description: "Get count of incidents by state", + }, + ]; + (response["data"] as Record)["example"] = { + createIncident: { + title: "Database connection failure", + description: "Production database is not responding to queries", + incidentSeverityId: "", + }, + }; + break; + + case "monitors": + (response["data"] as Record)["description"] = + "Monitors check the health and availability of your services (websites, APIs, servers)."; + (response["data"] as Record)["commonOperations"] = [ + { + tool: "list_monitors", + description: "List all monitors and their current status", + }, + { + tool: "create_monitor", + description: "Create a new monitor to watch a service", + }, + { + tool: "update_monitor", + description: "Update monitor configuration or enable/disable", + }, + { tool: "count_monitors", description: "Get total number of monitors" }, + ]; + break; + + case "alerts": + (response["data"] as Record)["description"] = + "Alerts are notifications sent when monitors detect issues."; + (response["data"] as Record)["commonOperations"] = [ + { + tool: "list_alerts", + description: "List all alerts and their status", + }, + { tool: "count_alerts", description: "Get count of alerts" }, + ]; + break; + + case "workflows": + (response["data"] as Record)["workflows"] = [ + { + name: "Check system status", + steps: [ + "1. Use count_incidents to see if there are active incidents", + "2. Use list_monitors with query to find any monitors with issues", + "3. Use list_incidents to get details of any active incidents", + ], + }, + { + name: "Create and manage incident", + steps: [ + "1. Use list_incident_states to get available states", + "2. Use create_incident with title, description, and severity", + "3. Use update_incident to change state as incident progresses", + ], + }, + { + name: "Incident summary report", + steps: [ + "1. Use count_incidents to get total count", + "2. Use list_incidents with sort by createdAt descending", + "3. Group and summarize the results", + ], + }, + ]; + break; + + case "examples": + (response["data"] as Record)["examples"] = { + listRecentIncidents: { + tool: "list_incidents", + args: { limit: 10, sort: { createdAt: -1 } }, + }, + countActiveIncidents: { + tool: "count_incidents", + args: { query: {} }, + }, + getSpecificIncident: { + tool: "get_incident", + args: { id: "" }, + }, + updateIncidentTitle: { + tool: "update_incident", + args: { id: "", title: "Updated title" }, + }, + }; + break; + + default: + (response["data"] as Record)["welcome"] = + "Welcome to OneUptime MCP Server!"; + (response["data"] as Record)["description"] = + "OneUptime is an open-source monitoring platform. This MCP server lets you manage incidents, monitors, alerts, and more."; + (response["data"] as Record)["availableTopics"] = [ + "resources", + "incidents", + "monitors", + "alerts", + "workflows", + "examples", + ]; + (response["data"] as Record)["quickStart"] = [ + "1. Use 'oneuptime_list_resources' to see all available resources", + "2. Use 'list_*' tools to browse existing data", + "3. Use 'count_*' tools to get quick summaries", + "4. Use 'create_*' tools to add new items", + ]; + (response["data"] as Record)["resourceCount"] = + resourceList.length; + break; + } + + return JSON.stringify(response, null, 2); +} + +function handleListResourcesTool(resources: ResourceInfo[]): string { + const response: Record = { + success: true, + totalResources: resources.length, + resources: resources.map((r: ResourceInfo) => { + return { + name: r.name, + singularName: r.singularName, + pluralName: r.pluralName, + description: r.description, + operations: r.operations, + tools: { + create: `create_${r.singularName.toLowerCase().replace(/\s+/g, "_")}`, + get: `get_${r.singularName.toLowerCase().replace(/\s+/g, "_")}`, + list: `list_${r.pluralName.toLowerCase().replace(/\s+/g, "_")}`, + update: `update_${r.singularName.toLowerCase().replace(/\s+/g, "_")}`, + delete: `delete_${r.singularName.toLowerCase().replace(/\s+/g, "_")}`, + count: `count_${r.pluralName.toLowerCase().replace(/\s+/g, "_")}`, + }, + }; + }), + }; + + return JSON.stringify(response, null, 2); +} + +/** + * Check if a tool is a helper tool (doesn't require API key) + */ +export function isHelperTool(toolName: string): boolean { + return ( + toolName === "oneuptime_help" || toolName === "oneuptime_list_resources" + ); +} + +/** + * Check if a tool doesn't require API key (helper or public status page tool) + */ +export function isPublicTool(toolName: string): boolean { + // Import check is done in ToolHandler to avoid circular dependencies + return isHelperTool(toolName); +} diff --git a/App/FeatureSet/MCP/Tools/PublicStatusPageTools.ts b/App/FeatureSet/MCP/Tools/PublicStatusPageTools.ts new file mode 100644 index 0000000000..a2a63891e9 --- /dev/null +++ b/App/FeatureSet/MCP/Tools/PublicStatusPageTools.ts @@ -0,0 +1,415 @@ +/** + * Public Status Page Tools + * Provides tools for querying public status pages without authentication + * These tools can be used with either a status page ID or domain name + */ + +import { McpToolInfo, JSONSchema } from "../Types/McpTypes"; +import OneUptimeOperation from "../Types/OneUptimeOperation"; +import ModelType from "../Types/ModelType"; +import MCPLogger from "../Utils/MCPLogger"; +import API from "Common/Utils/API"; +import URL from "Common/Types/API/URL"; +import Route from "Common/Types/API/Route"; +import Headers from "Common/Types/API/Headers"; +import HTTPResponse from "Common/Types/API/HTTPResponse"; +import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse"; +import { JSONObject } from "Common/Types/JSON"; +import { getApiUrl } from "../Config/ServerConfig"; + +// Common input schema for status page identifier +const statusPageIdentifierSchema: JSONSchema = { + type: "object", + properties: { + statusPageIdOrDomain: { + type: "string", + description: + "The status page ID (UUID) or domain name (e.g., 'status.company.com'). Use domain for public status pages with custom domains.", + }, + }, + required: ["statusPageIdOrDomain"], + additionalProperties: false, +}; + +/** + * Generate public status page tools + */ +export function generatePublicStatusPageTools(): McpToolInfo[] { + return [ + createGetOverviewTool(), + createGetIncidentsTool(), + createGetScheduledMaintenanceTool(), + createGetAnnouncementsTool(), + ]; +} + +function createGetOverviewTool(): McpToolInfo { + return { + name: "get_public_status_page_overview", + description: `Get the complete overview of a public status page including current status, resources, active incidents, scheduled maintenance, and announcements. + +This tool does NOT require an API key and works with public status pages. + +USAGE: +- By domain: statusPageIdOrDomain = "status.company.com" +- By ID: statusPageIdOrDomain = "550e8400-e29b-41d4-a716-446655440000" + +RETURNS: +- Status page metadata (name, description, branding) +- Resources and their current status +- Active incidents +- Upcoming scheduled maintenance +- Active announcements +- Monitor status history`, + inputSchema: statusPageIdentifierSchema, + modelName: "StatusPageOverview", + operation: OneUptimeOperation.Read, + modelType: ModelType.Database, + singularName: "Status Page Overview", + pluralName: "Status Page Overviews", + tableName: "StatusPageOverview", + apiPath: "/status-page", + }; +} + +function createGetIncidentsTool(): McpToolInfo { + return { + name: "get_public_status_page_incidents", + description: `Get incidents from a public status page. + +This tool does NOT require an API key and works with public status pages. + +USAGE: +- By domain: statusPageIdOrDomain = "status.company.com" +- By ID: statusPageIdOrDomain = "550e8400-e29b-41d4-a716-446655440000" + +RETURNS: +- List of incidents (active and recent history) +- Incident details (title, description, severity) +- Incident timeline and state changes +- Public notes/updates for each incident`, + inputSchema: { + type: "object", + properties: { + statusPageIdOrDomain: { + type: "string", + description: + "The status page ID (UUID) or domain name (e.g., 'status.company.com')", + }, + incidentId: { + type: "string", + description: "Optional: Specific incident ID to fetch details for", + }, + }, + required: ["statusPageIdOrDomain"], + additionalProperties: false, + }, + modelName: "StatusPageIncidents", + operation: OneUptimeOperation.List, + modelType: ModelType.Database, + singularName: "Status Page Incident", + pluralName: "Status Page Incidents", + tableName: "StatusPageIncidents", + apiPath: "/status-page", + }; +} + +function createGetScheduledMaintenanceTool(): McpToolInfo { + return { + name: "get_public_status_page_scheduled_maintenance", + description: `Get scheduled maintenance events from a public status page. + +This tool does NOT require an API key and works with public status pages. + +USAGE: +- By domain: statusPageIdOrDomain = "status.company.com" +- By ID: statusPageIdOrDomain = "550e8400-e29b-41d4-a716-446655440000" + +RETURNS: +- List of scheduled maintenance events (upcoming and ongoing) +- Maintenance details (title, description, scheduled times) +- Maintenance timeline and state changes +- Public notes/updates for each maintenance event`, + inputSchema: { + type: "object", + properties: { + statusPageIdOrDomain: { + type: "string", + description: + "The status page ID (UUID) or domain name (e.g., 'status.company.com')", + }, + scheduledMaintenanceId: { + type: "string", + description: + "Optional: Specific scheduled maintenance ID to fetch details for", + }, + }, + required: ["statusPageIdOrDomain"], + additionalProperties: false, + }, + modelName: "StatusPageScheduledMaintenance", + operation: OneUptimeOperation.List, + modelType: ModelType.Database, + singularName: "Status Page Scheduled Maintenance", + pluralName: "Status Page Scheduled Maintenances", + tableName: "StatusPageScheduledMaintenance", + apiPath: "/status-page", + }; +} + +function createGetAnnouncementsTool(): McpToolInfo { + return { + name: "get_public_status_page_announcements", + description: `Get announcements from a public status page. + +This tool does NOT require an API key and works with public status pages. + +USAGE: +- By domain: statusPageIdOrDomain = "status.company.com" +- By ID: statusPageIdOrDomain = "550e8400-e29b-41d4-a716-446655440000" + +RETURNS: +- List of active announcements +- Announcement details (title, description, dates)`, + inputSchema: { + type: "object", + properties: { + statusPageIdOrDomain: { + type: "string", + description: + "The status page ID (UUID) or domain name (e.g., 'status.company.com')", + }, + announcementId: { + type: "string", + description: + "Optional: Specific announcement ID to fetch details for", + }, + }, + required: ["statusPageIdOrDomain"], + additionalProperties: false, + }, + modelName: "StatusPageAnnouncements", + operation: OneUptimeOperation.List, + modelType: ModelType.Database, + singularName: "Status Page Announcement", + pluralName: "Status Page Announcements", + tableName: "StatusPageAnnouncements", + apiPath: "/status-page", + }; +} + +/** + * Check if a tool is a public status page tool + */ +export function isPublicStatusPageTool(toolName: string): boolean { + return ( + toolName === "get_public_status_page_overview" || + toolName === "get_public_status_page_incidents" || + toolName === "get_public_status_page_scheduled_maintenance" || + toolName === "get_public_status_page_announcements" + ); +} + +/** + * Handle public status page tool execution + */ +export async function handlePublicStatusPageTool( + toolName: string, + args: Record, +): Promise { + const statusPageIdOrDomain: string = args["statusPageIdOrDomain"] as string; + + if (!statusPageIdOrDomain) { + return JSON.stringify({ + success: false, + error: "statusPageIdOrDomain is required", + }); + } + + try { + switch (toolName) { + case "get_public_status_page_overview": + return await getStatusPageOverview(statusPageIdOrDomain); + + case "get_public_status_page_incidents": + return await getStatusPageIncidents( + statusPageIdOrDomain, + args["incidentId"] as string | undefined, + ); + + case "get_public_status_page_scheduled_maintenance": + return await getStatusPageScheduledMaintenance( + statusPageIdOrDomain, + args["scheduledMaintenanceId"] as string | undefined, + ); + + case "get_public_status_page_announcements": + return await getStatusPageAnnouncements( + statusPageIdOrDomain, + args["announcementId"] as string | undefined, + ); + + default: + return JSON.stringify({ + success: false, + error: `Unknown public status page tool: ${toolName}`, + }); + } + } catch (error) { + MCPLogger.error( + `Error executing public status page tool ${toolName}: ${error}`, + ); + return JSON.stringify({ + success: false, + error: `Failed to execute ${toolName}: ${error}`, + }); + } +} + +/** + * Get status page overview + * The backend now accepts both statusPageId and domain directly + */ +async function getStatusPageOverview( + statusPageIdOrDomain: string, +): Promise { + const response: JSONObject = await makeStatusPageApiRequest( + "POST", + `/api/status-page/overview/${statusPageIdOrDomain}`, + ); + + return JSON.stringify( + { + success: true, + operation: "get_overview", + statusPageIdOrDomain, + data: response, + }, + null, + 2, + ); +} + +/** + * Get status page incidents + * The backend now accepts both statusPageId and domain directly + */ +async function getStatusPageIncidents( + statusPageIdOrDomain: string, + incidentId?: string, +): Promise { + let route: string = `/api/status-page/incidents/${statusPageIdOrDomain}`; + if (incidentId) { + route = `/api/status-page/incidents/${statusPageIdOrDomain}/${incidentId}`; + } + + const response: JSONObject = await makeStatusPageApiRequest("POST", route); + + return JSON.stringify( + { + success: true, + operation: "get_incidents", + statusPageIdOrDomain, + incidentId: incidentId || null, + data: response, + }, + null, + 2, + ); +} + +/** + * Get status page scheduled maintenance events + * The backend now accepts both statusPageId and domain directly + */ +async function getStatusPageScheduledMaintenance( + statusPageIdOrDomain: string, + scheduledMaintenanceId?: string, +): Promise { + let route: string = `/api/status-page/scheduled-maintenance-events/${statusPageIdOrDomain}`; + if (scheduledMaintenanceId) { + route = `/api/status-page/scheduled-maintenance-events/${statusPageIdOrDomain}/${scheduledMaintenanceId}`; + } + + const response: JSONObject = await makeStatusPageApiRequest("POST", route); + + return JSON.stringify( + { + success: true, + operation: "get_scheduled_maintenance", + statusPageIdOrDomain, + scheduledMaintenanceId: scheduledMaintenanceId || null, + data: response, + }, + null, + 2, + ); +} + +/** + * Get status page announcements + * The backend now accepts both statusPageId and domain directly + */ +async function getStatusPageAnnouncements( + statusPageIdOrDomain: string, + announcementId?: string, +): Promise { + let route: string = `/api/status-page/announcements/${statusPageIdOrDomain}`; + if (announcementId) { + route = `/api/status-page/announcements/${statusPageIdOrDomain}/${announcementId}`; + } + + const response: JSONObject = await makeStatusPageApiRequest("POST", route); + + return JSON.stringify( + { + success: true, + operation: "get_announcements", + statusPageIdOrDomain, + announcementId: announcementId || null, + data: response, + }, + null, + 2, + ); +} + +/** + * Make a request to the StatusPage API + */ +async function makeStatusPageApiRequest( + method: "GET" | "POST", + path: string, + data?: JSONObject, +): Promise { + const apiUrl: string = getApiUrl(); + const url: URL = URL.fromString(apiUrl); + const route: Route = new Route(path); + const fullUrl: URL = new URL(url.protocol, url.hostname, route); + + const headers: Headers = { + "Content-Type": "application/json", + Accept: "application/json", + }; + + MCPLogger.info(`Making ${method} request to ${fullUrl.toString()}`); + + let response: HTTPResponse | HTTPErrorResponse; + + if (method === "GET") { + response = await API.get({ url: fullUrl, headers }); + } else { + response = await API.post({ url: fullUrl, headers, data: data || {} }); + } + + if (response instanceof HTTPErrorResponse) { + MCPLogger.error( + `API request failed: ${response.statusCode} - ${response.message}`, + ); + throw new Error( + `API request failed: ${response.statusCode} - ${response.message}`, + ); + } + + return response.data as JSONObject; +} diff --git a/App/FeatureSet/MCP/Tools/SchemaConverter.ts b/App/FeatureSet/MCP/Tools/SchemaConverter.ts new file mode 100644 index 0000000000..f4f6a28afa --- /dev/null +++ b/App/FeatureSet/MCP/Tools/SchemaConverter.ts @@ -0,0 +1,257 @@ +/** + * Schema Converter + * Converts Zod schemas to JSON Schema format for MCP tools + */ + +import { JSONSchemaProperty } from "../Types/McpTypes"; +import { ModelSchemaType } from "Common/Utils/Schema/ModelSchema"; +import { AnalyticsModelSchemaType } from "Common/Utils/Schema/AnalyticsModelSchema"; + +// Type for Zod field definition +interface ZodFieldDef { + typeName?: string; + innerType?: ZodField; + description?: string; + openapi?: { + metadata?: OpenApiMetadata; + }; +} + +// Type for Zod field +interface ZodField { + _def?: ZodFieldDef; +} + +// Type for OpenAPI metadata +interface OpenApiMetadata { + type?: string; + description?: string; + example?: unknown; + format?: string; + default?: unknown; + items?: JSONSchemaProperty; +} + +// Type for Zod schema with shape +interface ZodSchemaWithShape { + _def?: { + shape?: () => Record; + }; +} + +// Result type for schema conversion +export interface ZodToJsonSchemaResult { + type: string; + properties: Record; + required?: string[]; + additionalProperties: boolean; +} + +/** + * Convert a Zod schema to JSON Schema format for MCP tools + */ +export function zodToJsonSchema( + zodSchema: ModelSchemaType | AnalyticsModelSchemaType, +): ZodToJsonSchemaResult { + try { + const schemaWithShape: ZodSchemaWithShape = + zodSchema as unknown as ZodSchemaWithShape; + const shapeFunction: (() => Record) | undefined = + schemaWithShape._def?.shape; + + if (!shapeFunction) { + return createEmptySchema(); + } + + const shape: Record = shapeFunction(); + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + const { property, isRequired } = convertZodField(key, value); + properties[key] = property; + + if (isRequired) { + required.push(key); + } + } + + const result: ZodToJsonSchemaResult = { + type: "object", + properties, + additionalProperties: false, + }; + + if (required.length > 0) { + result.required = required; + } + + return result; + } catch { + return createEmptySchema(); + } +} + +/** + * Convert a single Zod field to JSON Schema property + */ +function convertZodField( + key: string, + zodField: ZodField, +): { property: JSONSchemaProperty; isRequired: boolean } { + // Handle ZodOptional fields by looking at the inner type + let actualField: ZodField = zodField; + let isOptional: boolean = false; + + if (zodField._def?.typeName === "ZodOptional") { + actualField = zodField._def.innerType || zodField; + isOptional = true; + } + + // Extract OpenAPI metadata + const openApiMetadata: OpenApiMetadata | undefined = + actualField._def?.openapi?.metadata || zodField._def?.openapi?.metadata; + + // Clean up description + const rawDescription: string = + zodField._def?.description || + openApiMetadata?.description || + `${key} field`; + const cleanDescription: string = cleanFieldDescription(rawDescription); + + let property: JSONSchemaProperty; + + if (openApiMetadata) { + property = buildPropertyFromMetadata( + openApiMetadata, + key, + cleanDescription, + ); + } else { + // Fallback for fields without OpenAPI metadata + property = { + type: "string", + description: cleanDescription, + }; + } + + return { + property, + isRequired: !isOptional, + }; +} + +/** + * Build JSON Schema property from OpenAPI metadata + */ +function buildPropertyFromMetadata( + metadata: OpenApiMetadata, + key: string, + description: string, +): JSONSchemaProperty { + const property: JSONSchemaProperty = { + type: metadata.type || "string", + description, + }; + + // Add optional fields if present + if (metadata.example !== undefined) { + (property as JSONSchemaProperty & { example: unknown }).example = + metadata.example; + } + + if (metadata.format) { + property.format = metadata.format; + } + + if (metadata.default !== undefined) { + property.default = metadata.default; + } + + // Handle array types + if (metadata.type === "array") { + property.items = metadata.items || { + type: "string", + description: `${key} item`, + }; + } + + return property; +} + +/** + * Clean up description by removing permission information + */ +export function cleanFieldDescription(description: string): string { + if (!description) { + return description; + } + + // Remove everything after ". Permissions -" + const permissionsIndex: number = description.indexOf(". Permissions -"); + if (permissionsIndex !== -1) { + const beforeText: string = description.substring(0, permissionsIndex); + return addPeriodIfNeeded(beforeText); + } + + // Handle cases where it starts with "Permissions -" without a preceding sentence + const permissionsStartIndex: number = description.indexOf("Permissions -"); + if (permissionsStartIndex !== -1) { + const beforePermissions: string = description + .substring(0, permissionsStartIndex) + .trim(); + if (beforePermissions && beforePermissions.length > 0) { + return addPeriodIfNeeded(beforePermissions); + } + } + + return description; +} + +/** + * Add period to text if it doesn't end with punctuation + */ +function addPeriodIfNeeded(text: string): string { + if (!text) { + return text; + } + + const punctuation: string[] = [".", "!", "?"]; + const lastChar: string = text.charAt(text.length - 1); + + if (punctuation.includes(lastChar)) { + return text; + } + + return text + "."; +} + +/** + * Create an empty schema result + */ +function createEmptySchema(): ZodToJsonSchemaResult { + return { + type: "object", + properties: {}, + additionalProperties: false, + }; +} + +/** + * Sanitize a name to be valid for MCP tool names + * MCP tool names can only contain [a-z0-9_-] + */ +export function sanitizeToolName(name: string): string { + return ( + name + // Convert camelCase to snake_case + .replace(/([a-z])([A-Z])/g, "$1_$2") + .toLowerCase() + // Replace non-alphanumeric characters with underscores + .replace(/[^a-z0-9]/g, "_") + // Replace multiple consecutive underscores with single underscore + .replace(/_+/g, "_") + // Remove leading/trailing underscores + .replace(/^_|_$/g, "") + ); +} diff --git a/App/FeatureSet/MCP/Tools/ToolGenerator.ts b/App/FeatureSet/MCP/Tools/ToolGenerator.ts new file mode 100644 index 0000000000..a009d8ff20 --- /dev/null +++ b/App/FeatureSet/MCP/Tools/ToolGenerator.ts @@ -0,0 +1,541 @@ +/** + * Tool Generator + * Generates MCP tools for OneUptime models + */ + +import DatabaseModels from "Common/Models/DatabaseModels/Index"; +import AnalyticsModels from "Common/Models/AnalyticsModels/Index"; +import DatabaseBaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel"; +import AnalyticsBaseModel from "Common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel"; +import { ModelSchema, ModelSchemaType } from "Common/Utils/Schema/ModelSchema"; +import { + AnalyticsModelSchema, + AnalyticsModelSchemaType, +} from "Common/Utils/Schema/AnalyticsModelSchema"; +import { McpToolInfo, ModelToolsResult } from "../Types/McpTypes"; +import OneUptimeOperation from "../Types/OneUptimeOperation"; +import ModelType from "../Types/ModelType"; +import { + zodToJsonSchema, + sanitizeToolName, + ZodToJsonSchemaResult, +} from "./SchemaConverter"; +import { generateHelperTools } from "./HelperTools"; +import { generatePublicStatusPageTools } from "./PublicStatusPageTools"; +import MCPLogger from "../Utils/MCPLogger"; + +/** + * Generate all MCP tools for all OneUptime models + */ +export function generateAllTools(): McpToolInfo[] { + const allTools: McpToolInfo[] = []; + + // Generate tools for Database Models + const databaseTools: McpToolInfo[] = generateDatabaseModelTools(); + allTools.push(...databaseTools); + + // Generate tools for Analytics Models + const analyticsTools: McpToolInfo[] = generateAnalyticsModelTools(); + allTools.push(...analyticsTools); + + // Generate helper tools for discovery and guidance + const helperTools: McpToolInfo[] = generateHelperTools(allTools); + allTools.push(...helperTools); + + // Generate public status page tools (no API key required) + const publicStatusPageTools: McpToolInfo[] = generatePublicStatusPageTools(); + allTools.push(...publicStatusPageTools); + + MCPLogger.info( + `Generated ${allTools.length} MCP tools for OneUptime models (including ${helperTools.length} helper tools and ${publicStatusPageTools.length} public status page tools)`, + ); + return allTools; +} + +/** + * Generate tools for all database models + */ +function generateDatabaseModelTools(): McpToolInfo[] { + const tools: McpToolInfo[] = []; + + for (const ModelClass of DatabaseModels) { + try { + const model: DatabaseBaseModel = new ModelClass(); + const result: ModelToolsResult = generateToolsForDatabaseModel( + model, + ModelClass, + ); + tools.push(...result.tools); + } catch (error) { + MCPLogger.error( + `Error generating tools for database model ${ModelClass.name}: ${error}`, + ); + } + } + + return tools; +} + +/** + * Generate tools for all analytics models + */ +function generateAnalyticsModelTools(): McpToolInfo[] { + const tools: McpToolInfo[] = []; + + for (const ModelClass of AnalyticsModels) { + try { + const model: AnalyticsBaseModel = new ModelClass(); + const result: ModelToolsResult = generateToolsForAnalyticsModel( + model, + ModelClass, + ); + tools.push(...result.tools); + } catch (error) { + MCPLogger.error( + `Error generating tools for analytics model ${ModelClass.name}: ${error}`, + ); + } + } + + return tools; +} + +/** + * Generate MCP tools for a specific database model + */ +export function generateToolsForDatabaseModel( + model: DatabaseBaseModel, + ModelClass: { new (): DatabaseBaseModel }, +): ModelToolsResult { + const modelName: string = model.tableName || ModelClass.name; + const singularName: string = model.singularName || modelName; + const pluralName: string = model.pluralName || `${singularName}s`; + const apiPath: string | undefined = model.crudApiPath?.toString(); + + const modelInfo: ModelToolsResult["modelInfo"] = { + tableName: modelName, + singularName, + pluralName, + modelType: ModelType.Database, + ...(apiPath && { apiPath }), + }; + + // Skip if model doesn't have required properties or MCP is disabled + if (!modelName || !model.enableMCP || !apiPath) { + return { tools: [], modelInfo }; + } + + // Generate schemas using ModelSchema + const createSchema: ModelSchemaType = ModelSchema.getCreateModelSchema({ + modelType: ModelClass, + }); + const updateSchema: ModelSchemaType = ModelSchema.getUpdateModelSchema({ + modelType: ModelClass, + }); + const querySchema: ModelSchemaType = ModelSchema.getQueryModelSchema({ + modelType: ModelClass, + }); + const sortSchema: ModelSchemaType = ModelSchema.getSortModelSchema({ + modelType: ModelClass, + }); + + const tools: McpToolInfo[] = [ + createCreateTool( + modelName, + singularName, + pluralName, + apiPath, + createSchema, + ), + createReadTool(modelName, singularName, pluralName, apiPath), + createListTool( + modelName, + singularName, + pluralName, + apiPath, + querySchema, + sortSchema, + ), + createUpdateTool( + modelName, + singularName, + pluralName, + apiPath, + updateSchema, + ), + createDeleteTool(modelName, singularName, pluralName, apiPath), + createCountTool(modelName, singularName, pluralName, apiPath, querySchema), + ]; + + return { tools, modelInfo }; +} + +/** + * Generate MCP tools for a specific analytics model + */ +export function generateToolsForAnalyticsModel( + model: AnalyticsBaseModel, + ModelClass: { new (): AnalyticsBaseModel }, +): ModelToolsResult { + const modelName: string = model.tableName || ModelClass.name; + const singularName: string = model.singularName || modelName; + const pluralName: string = model.pluralName || `${singularName}s`; + const apiPath: string | undefined = model.crudApiPath?.toString(); + + const modelInfo: ModelToolsResult["modelInfo"] = { + tableName: modelName, + singularName, + pluralName, + modelType: ModelType.Analytics, + apiPath, + }; + + // Skip if model doesn't have required properties or MCP is disabled + if (!modelName || !model.enableMCP || !apiPath) { + return { tools: [], modelInfo }; + } + + // Generate schemas using AnalyticsModelSchema + const createSchema: AnalyticsModelSchemaType = + AnalyticsModelSchema.getCreateModelSchema({ + modelType: ModelClass, + disableOpenApiSchema: true, + }); + const querySchema: AnalyticsModelSchemaType = + AnalyticsModelSchema.getQueryModelSchema({ + modelType: ModelClass, + disableOpenApiSchema: true, + }); + const selectSchema: AnalyticsModelSchemaType = + AnalyticsModelSchema.getSelectModelSchema({ + modelType: ModelClass, + }); + const sortSchema: AnalyticsModelSchemaType = + AnalyticsModelSchema.getSortModelSchema({ + modelType: ModelClass, + disableOpenApiSchema: true, + }); + + const tools: McpToolInfo[] = [ + createAnalyticsCreateTool( + modelName, + singularName, + pluralName, + apiPath, + createSchema, + ), + createAnalyticsListTool( + modelName, + singularName, + pluralName, + apiPath, + querySchema, + selectSchema, + sortSchema, + ), + createAnalyticsCountTool( + modelName, + singularName, + pluralName, + apiPath, + querySchema, + ), + ]; + + return { tools, modelInfo }; +} + +// Database Model Tool Creators + +function createCreateTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, + createSchema: ModelSchemaType, +): McpToolInfo { + const schemaProperties: ZodToJsonSchemaResult = zodToJsonSchema(createSchema); + + return { + name: `create_${sanitizeToolName(singularName)}`, + description: `Create a new ${singularName} in OneUptime. Returns the created ${singularName} object with its ID and all fields. Use this to add new ${pluralName} to your project.`, + inputSchema: { + type: "object", + properties: schemaProperties.properties || {}, + required: schemaProperties.required || [], + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.Create, + modelType: ModelType.Database, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} + +function createReadTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, +): McpToolInfo { + return { + name: `get_${sanitizeToolName(singularName)}`, + description: `Retrieve a single ${singularName} by its unique ID from OneUptime. Returns the complete ${singularName} object with all its fields. Use list_${sanitizeToolName(pluralName)} first if you need to find the ID.`, + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + description: `The unique identifier (UUID) of the ${singularName} to retrieve. Example: "550e8400-e29b-41d4-a716-446655440000"`, + }, + }, + required: ["id"], + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.Read, + modelType: ModelType.Database, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} + +function createListTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, + querySchema: ModelSchemaType, + sortSchema: ModelSchemaType, +): McpToolInfo { + return { + name: `list_${sanitizeToolName(pluralName)}`, + description: `List and search ${pluralName} from OneUptime with optional filtering, pagination, and sorting. Returns an array of ${singularName} objects. Use the 'query' parameter to filter results by specific field values. Supports pagination via 'skip' and 'limit' parameters.`, + inputSchema: { + type: "object", + properties: { + query: { + ...zodToJsonSchema(querySchema), + description: `Filter criteria for ${pluralName}. Each field can be used to filter results. Example: {"title": "My ${singularName}"} to find by title.`, + }, + skip: { + type: "number", + description: + "Number of records to skip for pagination. Default: 0. Example: skip=10 to start from the 11th record.", + }, + limit: { + type: "number", + description: + "Maximum number of records to return. Default: 10, Maximum: 100. Example: limit=25 to get 25 records.", + }, + sort: { + ...zodToJsonSchema(sortSchema), + description: `Sort order for results. Use 1 for ascending, -1 for descending. Example: {"createdAt": -1} to sort by newest first.`, + }, + }, + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.List, + modelType: ModelType.Database, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} + +function createUpdateTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, + updateSchema: ModelSchemaType, +): McpToolInfo { + const schemaProperties: ZodToJsonSchemaResult = zodToJsonSchema(updateSchema); + + return { + name: `update_${sanitizeToolName(singularName)}`, + description: `Update an existing ${singularName} in OneUptime. Only include the fields you want to change - unspecified fields will remain unchanged. Returns the updated ${singularName} object.`, + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + description: `The unique identifier (UUID) of the ${singularName} to update. Required. Use list_${sanitizeToolName(pluralName)} to find IDs.`, + }, + ...(schemaProperties.properties || {}), + }, + required: ["id"], + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.Update, + modelType: ModelType.Database, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} + +function createDeleteTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, +): McpToolInfo { + return { + name: `delete_${sanitizeToolName(singularName)}`, + description: `Permanently delete a ${singularName} from OneUptime. This action cannot be undone. Returns a confirmation message upon successful deletion.`, + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + description: `The unique identifier (UUID) of the ${singularName} to delete. This action is irreversible.`, + }, + }, + required: ["id"], + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.Delete, + modelType: ModelType.Database, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} + +function createCountTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, + querySchema: ModelSchemaType, +): McpToolInfo { + return { + name: `count_${sanitizeToolName(pluralName)}`, + description: `Count the total number of ${pluralName} in OneUptime, optionally filtered by query criteria. Returns a single number. Useful for dashboards, reports, or checking if records exist before listing.`, + inputSchema: { + type: "object", + properties: { + query: { + ...zodToJsonSchema(querySchema), + description: `Optional filter criteria. If omitted, counts all ${pluralName}. Example: {"currentIncidentStateId": "..."} to count incidents in a specific state.`, + }, + }, + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.Count, + modelType: ModelType.Database, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} + +// Analytics Model Tool Creators + +function createAnalyticsCreateTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, + createSchema: AnalyticsModelSchemaType, +): McpToolInfo { + const schemaProperties: ZodToJsonSchemaResult = zodToJsonSchema(createSchema); + + return { + name: `create_${sanitizeToolName(singularName)}`, + description: `Create a new ${singularName} analytics record in OneUptime`, + inputSchema: { + type: "object", + properties: schemaProperties.properties || {}, + required: schemaProperties.required || [], + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.Create, + modelType: ModelType.Analytics, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} + +function createAnalyticsListTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, + querySchema: AnalyticsModelSchemaType, + selectSchema: AnalyticsModelSchemaType, + sortSchema: AnalyticsModelSchemaType, +): McpToolInfo { + return { + name: `list_${sanitizeToolName(pluralName)}`, + description: `Query ${pluralName} analytics data from OneUptime`, + inputSchema: { + type: "object", + properties: { + query: zodToJsonSchema(querySchema), + select: zodToJsonSchema(selectSchema), + skip: { + type: "number", + description: "Number of records to skip", + }, + limit: { + type: "number", + description: "Maximum number of records to return", + }, + sort: zodToJsonSchema(sortSchema), + }, + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.List, + modelType: ModelType.Analytics, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} + +function createAnalyticsCountTool( + modelName: string, + singularName: string, + pluralName: string, + apiPath: string, + querySchema: AnalyticsModelSchemaType, +): McpToolInfo { + return { + name: `count_${sanitizeToolName(pluralName)}`, + description: `Count ${pluralName} analytics records in OneUptime`, + inputSchema: { + type: "object", + properties: { + query: zodToJsonSchema(querySchema), + }, + additionalProperties: false, + }, + modelName, + operation: OneUptimeOperation.Count, + modelType: ModelType.Analytics, + singularName, + pluralName, + tableName: modelName, + apiPath, + }; +} diff --git a/App/FeatureSet/MCP/Types/McpTypes.ts b/App/FeatureSet/MCP/Types/McpTypes.ts new file mode 100644 index 0000000000..4c689ae1c3 --- /dev/null +++ b/App/FeatureSet/MCP/Types/McpTypes.ts @@ -0,0 +1,68 @@ +import OneUptimeOperation from "./OneUptimeOperation"; +import ModelType from "./ModelType"; +import { JSONObject } from "Common/Types/JSON"; + +// JSON Schema type for MCP tool input schemas +export interface JSONSchemaProperty { + type: string; + description?: string; + enum?: Array; + items?: JSONSchemaProperty; + properties?: Record; + required?: string[]; + default?: unknown; + format?: string; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; +} + +export interface JSONSchema { + type: string; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + description?: string; +} + +export interface McpToolInfo { + name: string; + description: string; + inputSchema: JSONSchema; + modelName: string; + operation: OneUptimeOperation; + modelType: ModelType; + singularName: string; + pluralName: string; + tableName: string; + apiPath?: string; +} + +export interface ModelToolsResult { + tools: McpToolInfo[]; + modelInfo: { + tableName: string; + singularName: string; + pluralName: string; + modelType: ModelType; + apiPath?: string; + }; +} + +// Sort direction type +export type SortDirection = 1 | -1; + +// Sort object type +export type SortObject = Record; + +export interface OneUptimeToolCallArgs { + id?: string; + data?: JSONObject; + query?: JSONObject; + select?: JSONObject; + skip?: number; + limit?: number; + sort?: SortObject; +} diff --git a/App/FeatureSet/MCP/Types/ModelType.ts b/App/FeatureSet/MCP/Types/ModelType.ts new file mode 100644 index 0000000000..9438d35abe --- /dev/null +++ b/App/FeatureSet/MCP/Types/ModelType.ts @@ -0,0 +1,6 @@ +export enum ModelType { + Database = "database", + Analytics = "analytics", +} + +export default ModelType; diff --git a/App/FeatureSet/MCP/Types/OneUptimeOperation.ts b/App/FeatureSet/MCP/Types/OneUptimeOperation.ts new file mode 100644 index 0000000000..b940544cfa --- /dev/null +++ b/App/FeatureSet/MCP/Types/OneUptimeOperation.ts @@ -0,0 +1,10 @@ +export enum OneUptimeOperation { + Create = "create", + Read = "read", + List = "list", + Update = "update", + Delete = "delete", + Count = "count", +} + +export default OneUptimeOperation; diff --git a/App/FeatureSet/MCP/Utils/MCPLogger.ts b/App/FeatureSet/MCP/Utils/MCPLogger.ts new file mode 100644 index 0000000000..60ee2bcb16 --- /dev/null +++ b/App/FeatureSet/MCP/Utils/MCPLogger.ts @@ -0,0 +1,84 @@ +/** + * MCP Logger - A logger specifically designed for MCP servers + * All logs are directed to stderr to avoid interfering with the JSON-RPC protocol on stdout + */ + +import { LogLevel } from "Common/Server/EnvironmentConfig"; +import ConfigLogLevel from "Common/Server/Types/ConfigLogLevel"; +import { JSONObject } from "Common/Types/JSON"; +import Exception from "Common/Types/Exception/Exception"; + +export type LogBody = string | JSONObject | Exception | Error | unknown; + +export default class MCPLogger { + public static getLogLevel(): ConfigLogLevel { + if (!LogLevel) { + return ConfigLogLevel.INFO; + } + + return LogLevel; + } + + public static serializeLogBody(body: LogBody): string { + if (typeof body === "string") { + return body; + } else if (body instanceof Exception || body instanceof Error) { + return body.message; + } + return JSON.stringify(body); + } + + public static info(message: LogBody): void { + const logLevel: ConfigLogLevel = this.getLogLevel(); + + if (logLevel === ConfigLogLevel.DEBUG || logLevel === ConfigLogLevel.INFO) { + // Use stderr instead of stdout for MCP compatibility + process.stderr.write(`[INFO] ${this.serializeLogBody(message)}\n`); + } + } + + public static error(message: LogBody): void { + const logLevel: ConfigLogLevel = this.getLogLevel(); + + if ( + logLevel === ConfigLogLevel.DEBUG || + logLevel === ConfigLogLevel.INFO || + logLevel === ConfigLogLevel.WARN || + logLevel === ConfigLogLevel.ERROR + ) { + // Use stderr for error messages + process.stderr.write(`[ERROR] ${this.serializeLogBody(message)}\n`); + } + } + + public static warn(message: LogBody): void { + const logLevel: ConfigLogLevel = this.getLogLevel(); + + if ( + logLevel === ConfigLogLevel.DEBUG || + logLevel === ConfigLogLevel.INFO || + logLevel === ConfigLogLevel.WARN + ) { + // Use stderr for warning messages + process.stderr.write(`[WARN] ${this.serializeLogBody(message)}\n`); + } + } + + public static debug(message: LogBody): void { + const logLevel: ConfigLogLevel = this.getLogLevel(); + + if (logLevel === ConfigLogLevel.DEBUG) { + // Use stderr for debug messages + process.stderr.write(`[DEBUG] ${this.serializeLogBody(message)}\n`); + } + } + + public static trace(message: LogBody): void { + const logLevel: ConfigLogLevel = this.getLogLevel(); + + if (logLevel === ConfigLogLevel.DEBUG) { + // Use stderr for trace messages + process.stderr.write(`[TRACE] ${this.serializeLogBody(message)}\n`); + } + } +} diff --git a/App/Index.ts b/App/Index.ts index 6ce57fe8a5..8071fd1b99 100755 --- a/App/Index.ts +++ b/App/Index.ts @@ -1,6 +1,7 @@ import BaseAPIRoutes from "./FeatureSet/BaseAPI/Index"; // import FeatureSets. import IdentityRoutes from "./FeatureSet/Identity/Index"; +import MCPRoutes from "./FeatureSet/MCP/Index"; import NotificationRoutes from "./FeatureSet/Notification/Index"; import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; import { ClickhouseAppInstance } from "Common/Server/Infrastructure/ClickhouseDatabase"; @@ -94,6 +95,7 @@ const init: PromiseVoidFunction = async (): Promise => { await IdentityRoutes.init(); await NotificationRoutes.init(); await BaseAPIRoutes.init(); + await MCPRoutes.init(); // Add default routes to the app await App.addDefaultRoutes(); diff --git a/App/package.json b/App/package.json index fdbcd3e32d..6b11fe223e 100644 --- a/App/package.json +++ b/App/package.json @@ -20,6 +20,7 @@ "author": "OneUptime (https://oneuptime.com/)", "license": "Apache-2.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.0", "@sendgrid/mail": "^8.1.0", "Common": "file:../Common", "ejs": "^3.1.9", diff --git a/HelmChart/Public/oneuptime/templates/_helpers.tpl b/HelmChart/Public/oneuptime/templates/_helpers.tpl index 26e4871e34..ad1ab0896d 100644 --- a/HelmChart/Public/oneuptime/templates/_helpers.tpl +++ b/HelmChart/Public/oneuptime/templates/_helpers.tpl @@ -131,9 +131,6 @@ Usage: value: {{ $.Release.Name }}-admin-dashboard.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }} - name: SERVER_DOCS_HOSTNAME value: {{ $.Release.Name }}-docs.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }} -- name: SERVER_MCP_HOSTNAME - value: {{ $.Release.Name }}-mcp.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }} - - name: APP_PORT value: {{ $.Values.app.ports.http | squote }} - name: TELEMETRY_PORT @@ -158,8 +155,6 @@ Usage: value: {{ $.Values.adminDashboard.ports.http | squote }} - name: DOCS_PORT value: {{ $.Values.docs.ports.http | squote }} -- name: MCP_PORT - value: {{ $.Values.mcp.ports.http | squote }} {{- end }} diff --git a/HelmChart/Public/oneuptime/templates/mcp.yaml b/HelmChart/Public/oneuptime/templates/mcp.yaml deleted file mode 100644 index fe3a00efde..0000000000 --- a/HelmChart/Public/oneuptime/templates/mcp.yaml +++ /dev/null @@ -1,19 +0,0 @@ -{{- if $.Values.mcp.enabled }} -# OneUptime MCP Deployment -{{- $mcpEnv := dict "PORT" $.Values.mcp.ports.http "DISABLE_TELEMETRY" $.Values.mcp.disableTelemetryCollection -}} -{{- $mcpPorts := $.Values.mcp.ports -}} -{{- $mcpDeploymentArgs := dict "ServiceName" "mcp" "Ports" $mcpPorts "Release" $.Release "Values" $.Values "Env" $mcpEnv "Resources" $.Values.mcp.resources "NodeSelector" $.Values.mcp.nodeSelector "PodSecurityContext" $.Values.mcp.podSecurityContext "ContainerSecurityContext" $.Values.mcp.containerSecurityContext "DisableAutoscaler" $.Values.mcp.disableAutoscaler "ReplicaCount" $.Values.mcp.replicaCount -}} -{{- include "oneuptime.deployment" $mcpDeploymentArgs }} ---- - -# OneUptime MCP autoscaler -{{- $mcpAutoScalerArgs := dict "ServiceName" "mcp" "Release" $.Release "Values" $.Values "DisableAutoscaler" $.Values.mcp.disableAutoscaler -}} -{{- include "oneuptime.autoscaler" $mcpAutoScalerArgs }} ---- -{{- end }} - -# OneUptime MCP Service -{{- $mcpPorts := $.Values.mcp.ports -}} -{{- $mcpServiceArgs := dict "ServiceName" "mcp" "Ports" $mcpPorts "Release" $.Release "Values" $.Values -}} -{{- include "oneuptime.service" $mcpServiceArgs }} ---- diff --git a/HelmChart/Public/oneuptime/values.schema.json b/HelmChart/Public/oneuptime/values.schema.json index 0ec188eb77..9327a16df0 100644 --- a/HelmChart/Public/oneuptime/values.schema.json +++ b/HelmChart/Public/oneuptime/values.schema.json @@ -1799,45 +1799,6 @@ }, "additionalProperties": false }, - "mcp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "replicaCount": { - "type": "integer" - }, - "disableTelemetryCollection": { - "type": "boolean" - }, - "disableAutoscaler": { - "type": "boolean" - }, - "ports": { - "type": "object", - "properties": { - "http": { - "type": "integer" - } - }, - "additionalProperties": false - }, - "resources": { - "type": ["object", "null"] - }, - "nodeSelector": { - "type": "object" - }, - "podSecurityContext": { - "type": "object" - }, - "containerSecurityContext": { - "type": "object" - } - }, - "additionalProperties": false - }, "slackApp": { "type": "object", "properties": { diff --git a/HelmChart/Public/oneuptime/values.yaml b/HelmChart/Public/oneuptime/values.yaml index 8e1cf92f5b..e2e13a7e88 100644 --- a/HelmChart/Public/oneuptime/values.yaml +++ b/HelmChart/Public/oneuptime/values.yaml @@ -732,18 +732,6 @@ isolatedVM: podSecurityContext: {} containerSecurityContext: {} -mcp: - enabled: true - replicaCount: 1 - disableTelemetryCollection: false - disableAutoscaler: false - ports: - http: 3405 - resources: - nodeSelector: {} - podSecurityContext: {} - containerSecurityContext: {} - # AI Agent Configuration # Deploy this to run an AI Agent within your Kubernetes cluster # Note: This is disabled by default. To enable, set enabled to true and provide the AI Agent credentials diff --git a/Home/Routes.ts b/Home/Routes.ts index 6601ebb3b4..58975e38b5 100755 --- a/Home/Routes.ts +++ b/Home/Routes.ts @@ -1579,6 +1579,18 @@ const HomeFeatureSet: FeatureSet = { }, ); + app.get("/legal/dpa", (_req: ExpressRequest, res: ExpressResponse) => { + res.render(`${ViewsPath}/legal.ejs`, { + footerCards: true, + support: false, + enableGoogleTagManager: IsBillingEnabled, + cta: true, + blackLogo: false, + section: "dpa", + requestDemoCta: false, + }); + }); + app.get("/legal/ccpa", (_req: ExpressRequest, res: ExpressResponse) => { res.render(`${ViewsPath}/legal.ejs`, { support: false, diff --git a/Home/Views/dpa.ejs b/Home/Views/dpa.ejs new file mode 100644 index 0000000000..9f5e0b982c --- /dev/null +++ b/Home/Views/dpa.ejs @@ -0,0 +1,221 @@ +
+

Data Processing Agreement

+

Last updated: March 3, 2026

+
+ +
+ +

1. Introduction

+

+ This Data Processing Agreement ("DPA") forms part of the Customer Terms of Service or other written agreement + between HackerBay, Inc. doing business as OneUptime ("Processor", "we", "us") and the entity agreeing to these + terms ("Controller", "Customer", "you") for the provision of OneUptime services (the "Agreement"). +

+

+ This DPA reflects the parties' commitment to abide by applicable Data Protection Laws concerning the processing + of Personal Data in connection with the Services provided under the Agreement. +

+ +

2. Definitions

+

In this DPA, the following terms have the meanings set out below. Capitalized terms not defined herein have the + meanings given to them in the Agreement.

+
    +
  • "Controller" means the entity that determines the purposes and means of Processing Personal + Data, as defined in applicable Data Protection Laws.
  • +
  • "Data Protection Laws" means all applicable legislation relating to data protection and + privacy, including the EU General Data Protection Regulation (GDPR) 2016/679, the California Consumer Privacy + Act (CCPA), and any applicable national implementing or supplementary legislation.
  • +
  • "Data Subject" means an identified or identifiable natural person to whom Personal Data + relates.
  • +
  • "Personal Data" means any information relating to a Data Subject that is processed by the + Processor on behalf of the Controller in connection with the Services.
  • +
  • "Processing" (and "Process") means any operation performed on Personal Data, including + collection, recording, organization, structuring, storage, adaptation, alteration, retrieval, consultation, + use, disclosure, dissemination, restriction, erasure, or destruction.
  • +
  • "Processor" means the entity that Processes Personal Data on behalf of the Controller, as + defined in applicable Data Protection Laws.
  • +
  • "Security Incident" means any accidental or unlawful destruction, loss, alteration, + unauthorized disclosure of, or access to Personal Data.
  • +
  • "Services" means the monitoring and incident management services provided by OneUptime to the + Customer under the Agreement.
  • +
  • "Subprocessor" means any third party engaged by the Processor to Process Personal Data on + behalf of the Controller. A current list of Subprocessors is available at + /legal/subprocessors.
  • +
+ +

3. Scope and Purpose of Processing

+

3.1 Scope

+

+ This DPA applies to the Processing of Personal Data by the Processor on behalf of the Controller in connection + with the provision of the Services under the Agreement. +

+

3.2 Purpose

+

+ The Processor shall Process Personal Data only for the purposes of providing the Services as described in the + Agreement and as further documented in the Controller's instructions. The categories of Personal Data and Data + Subjects are determined by the Controller's use of the Services and typically include: +

+
    +
  • Categories of Data Subjects: Customer's end users, employees, contractors, and other + individuals whose data is submitted to the Services.
  • +
  • Types of Personal Data: Name, email address, IP address, user agent information, and any + other data submitted through the Services by the Controller.
  • +
  • Processing Activities: Storage, analysis, monitoring, alerting, and incident management as + necessary to provide the Services.
  • +
+ +

4. Obligations of the Processor

+

4.1 Processing Instructions

+

+ The Processor shall Process Personal Data only on documented instructions from the Controller, including with + regard to transfers of Personal Data to a third country, unless required to do so by applicable law. In such a + case, the Processor shall inform the Controller of that legal requirement before Processing, unless prohibited + by law. +

+

4.2 Confidentiality

+

+ The Processor shall ensure that persons authorized to Process Personal Data have committed themselves to + confidentiality or are under an appropriate statutory obligation of confidentiality. +

+

4.3 Security Measures

+

+ The Processor shall implement appropriate technical and organizational measures to ensure a level of security + appropriate to the risk, including as appropriate: +

+
    +
  • Encryption of Personal Data in transit and at rest
  • +
  • Measures to ensure ongoing confidentiality, integrity, availability, and resilience of processing systems
  • +
  • The ability to restore the availability and access to Personal Data in a timely manner in the event of an + incident
  • +
  • Regular testing, assessing, and evaluating the effectiveness of technical and organizational measures
  • +
+

4.4 Subprocessing

+

+ The Processor shall not engage another processor (Subprocessor) without prior specific or general written + authorization of the Controller. In the case of general written authorization, the Processor shall inform the + Controller of any intended changes concerning the addition or replacement of Subprocessors, giving the Controller + the opportunity to object to such changes. A current list of Subprocessors is maintained at + /legal/subprocessors. +

+

+ Where the Processor engages a Subprocessor, the Processor shall impose on the Subprocessor the same data + protection obligations as set out in this DPA by way of a written contract, ensuring that the Subprocessor + provides sufficient guarantees to implement appropriate technical and organizational measures. +

+

4.5 Assistance to the Controller

+

+ Taking into account the nature of the Processing, the Processor shall assist the Controller by appropriate + technical and organizational measures, insofar as possible, for the fulfilment of the Controller's obligation to + respond to requests from Data Subjects exercising their rights under Data Protection Laws. +

+ +

5. Obligations of the Controller

+

The Controller warrants and represents that:

+
    +
  • It has complied and will continue to comply with all applicable Data Protection Laws in respect of its use of + the Services and its Processing instructions to the Processor.
  • +
  • It has obtained all necessary consents or has another lawful basis for the transfer of Personal Data to the + Processor for Processing in accordance with this DPA.
  • +
  • It shall be responsible for the accuracy, quality, and legality of Personal Data and the means by which it + acquired the Personal Data.
  • +
  • It shall inform the Processor without undue delay if it becomes aware of any circumstances that could affect + the lawfulness of the Processing of Personal Data under this DPA.
  • +
+ +

6. Data Subject Rights

+

+ The Processor shall, to the extent legally permitted, promptly notify the Controller if the Processor receives a + request from a Data Subject to exercise any of the following rights with respect to their Personal Data: +

+
    +
  • Right of access
  • +
  • Right to rectification
  • +
  • Right to erasure ("right to be forgotten")
  • +
  • Right to restriction of processing
  • +
  • Right to data portability
  • +
  • Right to object to processing
  • +
  • Rights related to automated decision-making and profiling
  • +
+

+ The Processor shall not independently respond to such requests except on the documented instructions of the + Controller or as required by applicable law. +

+ +

7. Security Incident Notification

+

+ The Processor shall notify the Controller without undue delay after becoming aware of a Security Incident + affecting Personal Data processed on behalf of the Controller. Such notification shall include: +

+
    +
  • A description of the nature of the Security Incident, including the categories and approximate number of + Data Subjects and Personal Data records affected
  • +
  • The name and contact details of the Processor's data protection contact
  • +
  • A description of the likely consequences of the Security Incident
  • +
  • A description of the measures taken or proposed to be taken to address the Security Incident, including + measures to mitigate its possible adverse effects
  • +
+

+ The Processor shall cooperate with the Controller and take reasonable commercial steps to assist in the + investigation, mitigation, and remediation of each Security Incident. +

+ +

8. International Data Transfers

+

+ The Processor shall not transfer Personal Data to a country outside the European Economic Area (EEA) unless + appropriate safeguards are in place as required by applicable Data Protection Laws, such as: +

+
    +
  • Standard Contractual Clauses (SCCs) approved by the European Commission
  • +
  • Binding Corporate Rules
  • +
  • An adequacy decision by the European Commission for the recipient country
  • +
  • The EU-U.S. Data Privacy Framework, where applicable
  • +
+

+ For information about where Customer Data is stored, please refer to our + Data Residency page. +

+ +

9. Duration and Termination

+

+ This DPA shall remain in effect for the duration of the Agreement. Upon termination of the Agreement, the + Processor shall, at the choice of the Controller, delete or return all Personal Data to the Controller and delete + existing copies, unless applicable law requires storage of the Personal Data. The Processor shall certify in + writing that it has complied with this provision upon the Controller's request. +

+ +

10. Audit Rights

+

+ The Processor shall make available to the Controller all information necessary to demonstrate compliance with the + obligations laid down in this DPA and applicable Data Protection Laws, and shall allow for and contribute to + audits, including inspections, conducted by the Controller or another auditor mandated by the Controller. +

+

+ The Controller shall provide reasonable prior written notice of any audit request. Audits shall be conducted + during normal business hours and shall not unreasonably interfere with the Processor's business operations. The + Controller shall bear the costs of any such audit unless the audit reveals a material breach of this DPA by the + Processor. +

+ +

11. Liability

+

+ Each party's liability arising out of or related to this DPA is subject to the limitations of liability set forth + in the Agreement. In no event shall either party's aggregate liability for claims arising out of or related to + this DPA exceed the limitations set forth in the Agreement. +

+ +

12. Contact Information

+

+ For questions or concerns about this DPA or our data processing practices, please contact us: +

+
    +
  • Entity: HackerBay, Inc. (doing business as OneUptime)
  • +
  • Email: legal@oneuptime.com
  • +
  • Address: 325 N Wells St, Chicago, IL 60654, United States
  • +
+

+ For more information about our privacy practices, please see our + Privacy Policy. For details on GDPR compliance, visit our + GDPR page. +

+ +
diff --git a/Home/Views/legal.ejs b/Home/Views/legal.ejs index 2d338cda66..c23fc49689 100755 --- a/Home/Views/legal.ejs +++ b/Home/Views/legal.ejs @@ -310,6 +310,7 @@ + @@ -395,6 +396,11 @@ Subprocessors +
  • + + Data Processing Agreement + +
  • @@ -563,6 +569,10 @@ <%- include('subprocessors') -%> <% } %> + <% if(section === "dpa") { %> + <%- include('dpa') -%> + <% } %> + <% if(section === "vpat") { %> <%- include('vpat') -%> <% } %> diff --git a/Nginx/default.conf.template b/Nginx/default.conf.template index 9025c8920e..835a88ae17 100644 --- a/Nginx/default.conf.template +++ b/Nginx/default.conf.template @@ -56,10 +56,6 @@ upstream opentelemetry-collector-grpc { server ${SERVER_OTEL_COLLECTOR_HOSTNAME}:4317; } -upstream mcp { - server ${SERVER_MCP_HOSTNAME}:${MCP_PORT} weight=10 max_fails=3 fail_timeout=30s; -} - # Status Pages server { @@ -960,6 +956,6 @@ ${PROVISION_SSL_CERTIFICATE_KEY_DIRECTIVE} proxy_send_timeout 86400s; chunked_transfer_encoding on; - proxy_pass http://mcp; + proxy_pass http://app; } } diff --git a/config.example.env b/config.example.env index 867dd15c33..7bc800a4a1 100644 --- a/config.example.env +++ b/config.example.env @@ -104,7 +104,6 @@ SERVER_ADMIN_DASHBOARD_HOSTNAME=admin-dashboard SERVER_OTEL_COLLECTOR_HOSTNAME=otel-collector SERVER_WORKER_HOSTNAME=worker SERVER_DOCS_HOSTNAME=docs -SERVER_MCP_HOSTNAME=mcp #Ports. Usually they don't need to change. @@ -121,7 +120,6 @@ HOME_PORT=1444 WORKER_PORT=1445 WORKFLOW_PORT=3099 DOCS_PORT=1447 -MCP_PORT=3405 # Plans # This is in the format of PlanName,PlanIdFromBillingProvider,MonthlySubscriptionPlanAmountInUSD,YearlySubscriptionPlanAmountInUSD,Order,TrialPeriodInDays @@ -317,7 +315,6 @@ DISABLE_TELEMETRY_FOR_ISOLATED_VM=true DISABLE_TELEMETRY_FOR_INGRESS=true DISABLE_TELEMETRY_FOR_WORKER=true -DISABLE_TELEMETRY_FOR_MCP=true DISABLE_TELEMETRY_FOR_AI_AGENT=true diff --git a/docker-compose.base.yml b/docker-compose.base.yml index d9f5605b4e..cdb61ac72b 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -42,7 +42,6 @@ x-common-variables: &common-variables SERVER_HOME_HOSTNAME: home SERVER_WORKFLOW_HOSTNAME: workflow SERVER_DOCS_HOSTNAME: docs - SERVER_MCP_HOSTNAME: mcp #Ports. Usually they don't need to change. APP_PORT: ${APP_PORT} @@ -57,7 +56,6 @@ x-common-variables: &common-variables WORKER_PORT: ${WORKER_PORT} WORKFLOW_PORT: ${WORKFLOW_PORT} DOCS_PORT: ${DOCS_PORT} - MCP_PORT: ${MCP_PORT} OPENTELEMETRY_EXPORTER_OTLP_ENDPOINT: ${OPENTELEMETRY_EXPORTER_OTLP_ENDPOINT} OPENTELEMETRY_EXPORTER_OTLP_HEADERS: ${OPENTELEMETRY_EXPORTER_OTLP_HEADERS} @@ -502,19 +500,6 @@ services: options: max-size: "1000m" - mcp: - networks: - - oneuptime - restart: always - environment: - <<: *common-runtime-variables - PORT: ${MCP_PORT} - DISABLE_TELEMETRY: ${DISABLE_TELEMETRY_FOR_MCP} - logging: - driver: "local" - options: - max-size: "1000m" - e2e: restart: "no" network_mode: host # This is needed to access the host network, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2d9eb016ca..0fc4378dd1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -377,26 +377,6 @@ services: context: . dockerfile: ./Telemetry/Dockerfile - mcp: - volumes: - - ./MCP:/usr/src/app:cached - # Use node modules of the container and not host system. - # https://stackoverflow.com/questions/29181032/add-a-volume-to-docker-but-exclude-a-sub-folder - - /usr/src/app/node_modules/ - - ./Common:/usr/src/Common:cached - - /usr/src/Common/node_modules/ - ports: - - '9945:9229' # Debugging port. - extends: - file: ./docker-compose.base.yml - service: mcp - depends_on: - <<: *common-depends-on - build: - network: host - context: . - dockerfile: ./MCP/Dockerfile - # Fluentd. Required only for development. In production its the responsibility of the customer to run fluentd and pipe logs to OneUptime. # We run this container just for development, to see if logs are piped. diff --git a/docker-compose.yml b/docker-compose.yml index f2260bfc62..82b233683a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -129,14 +129,6 @@ services: file: ./docker-compose.base.yml service: isolated-vm - mcp: - image: oneuptime/mcp:${APP_TAG} - extends: - file: ./docker-compose.base.yml - service: mcp - depends_on: - <<: *common-depends-on - ingress: image: oneuptime/nginx:${APP_TAG} extends: