feat(opax-web): add secure MCP agent client
This commit is contained in:
parent
8e89c4671a
commit
b0bdca55b0
145
opax-web/backend/agentClient.js
Normal file
145
opax-web/backend/agentClient.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user