From b0bdca55b05502a07fd06027b2c49f22698cebea Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Mon, 7 Sep 2026 21:15:45 +0000 Subject: [PATCH] feat(opax-web): add secure MCP agent client --- opax-web/backend/agentClient.js | 145 ++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 opax-web/backend/agentClient.js diff --git a/opax-web/backend/agentClient.js b/opax-web/backend/agentClient.js new file mode 100644 index 0000000..bbbd619 --- /dev/null +++ b/opax-web/backend/agentClient.js @@ -0,0 +1,145 @@ +import { randomUUID } from 'node:crypto'; + +const TIMEOUT_MS = 10000; +const MAX_REQUEST_SIZE = 16384; +const MAX_RESPONSE_SIZE = 1024 * 1024; +const GENERIC_ERROR_MESSAGE = 'Agent request failed.'; + +const READ_ONLY_TOOLS = new Set([ + 'get_health', + 'get_build_status', + 'get_state', + 'get_telemetry', + 'get_git_status', + 'get_git_branch', + 'get_git_log', + 'get_git_diff', + 'list_repo_files', + 'read_repo_file', +]); + +export class AgentNotConfiguredError extends Error { + constructor() { + super('Agent integration is not configured.'); + this.name = 'AgentNotConfiguredError'; + } +} + +function validateMcpUrl(urlString, allowedOrigin) { + const url = new URL(urlString); + + if (url.username || url.password || url.hash) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + if (url.protocol === 'http:') { + const isLoopback = + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '[::1]'; + + if (!isLoopback) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return url; + } + + if (url.protocol !== 'https:' || !allowedOrigin) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const configuredOrigin = new URL(allowedOrigin); + + if (url.origin !== configuredOrigin.origin) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return url; +} + +export async function callAgent(tool, args) { + const mcpUrl = process.env.OPAX_MCP_URL?.trim(); + const mcpSecret = process.env.MCP_SECRET?.trim(); + + if (!mcpUrl || !mcpSecret) { + throw new AgentNotConfiguredError(); + } + + try { + const allowedOrigin = process.env.OPAX_MCP_ALLOWED_ORIGIN?.trim(); + + if (!READ_ONLY_TOOLS.has(tool)) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const validatedUrl = validateMcpUrl(mcpUrl, allowedOrigin); + + const requestBody = JSON.stringify({ + jsonrpc: '2.0', + id: randomUUID(), + method: 'tools/call', + params: { + name: tool, + arguments: args, + }, + }); + + if (requestBody.length > MAX_REQUEST_SIZE) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const response = await fetch(validatedUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${mcpSecret}`, + }, + body: requestBody, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const responseText = await response.text(); + + if (responseText.length > MAX_RESPONSE_SIZE) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const rpcResponse = JSON.parse(responseText); + + if (rpcResponse.error) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const content = rpcResponse.result?.content; + + if (!Array.isArray(content) || content.length === 0) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const firstContent = content[0]; + + if ( + firstContent?.type !== 'text' || + typeof firstContent.text !== 'string' || + firstContent.text.length > MAX_RESPONSE_SIZE + ) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return JSON.parse(firstContent.text); + } finally { + clearTimeout(timeoutId); + } + } catch { + throw new Error(GENERIC_ERROR_MESSAGE); + } +}