From 4e9b84fa6aa9a17e88551d220dac1da122d73b73 Mon Sep 17 00:00:00 2001 From: joelorzet Date: Thu, 14 May 2026 16:40:16 -0300 Subject: [PATCH 1/2] hotfix: pin pnpm@10 in event-tracker Dockerfile The Dockerfile installed pnpm via 'npm install -g pnpm' which resolves to the latest tag. pnpm 11.1.2 (published 2026-05-14 11:44 UTC) tightened overrides validation, causing 'pnpm install --frozen-lockfile' to fail with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH against the existing keeperhub-events/pnpm-lock.yaml (generated with pnpm 10). Pin to pnpm@10 to match the version used to author the lockfile and the pinning convention already used by the root, sandbox, and scheduler Dockerfiles. --- keeperhub-events/event-tracker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keeperhub-events/event-tracker/Dockerfile b/keeperhub-events/event-tracker/Dockerfile index be34dee213..48980307a8 100644 --- a/keeperhub-events/event-tracker/Dockerfile +++ b/keeperhub-events/event-tracker/Dockerfile @@ -5,7 +5,7 @@ # Base stage - common setup # ============================================================================== FROM node:22-alpine AS base -RUN npm install -g pnpm +RUN npm install -g pnpm@10 WORKDIR /app # ============================================================================== From 1cc0ed2729dea4d91fd8408804ac9e79e380d8f2 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Fri, 15 May 2026 09:03:13 +1200 Subject: [PATCH 2/2] fix: route MCP server-to-server API calls over in-pod loopback The MCP route handlers and the /api/* routes they call (execute, workflow invocation, status, resources) run in the same Next.js process. They were being called through the public hostname (NEXT_PUBLIC_APP_URL, app.keeperhub.com), so each internal call exited the pod to the Cloudflare edge. Once CF's "Block AI Scrapers and Crawlers" managed challenge was enabled, those pod-to-edge requests started getting a managed challenge and 403ing tool execution (execute_contract_call, execute_transfer, etc.). getBaseUrl returning the public URL is correct for OAuth metadata documents the client must reach. The defect was reusing that same value for the server's own internal fetches. Adds getInternalApiBaseUrl() (in-pod loopback, INTERNAL_API_URL override) and routes createMcpServer / createWorkflowMcpServer through it. OAuth-metadata uses of getBaseUrl are unchanged. The threaded parameter is renamed baseUrl -> internalApiBaseUrl so it cannot be silently re-wired to the public URL again. --- app/mcp/route.ts | 13 ++--- app/mcp/w/[slug]/route.ts | 13 ++--- lib/mcp/internal-url.ts | 23 +++++++++ lib/mcp/server.ts | 26 ++++++---- lib/mcp/tools.ts | 66 +++++++++++++------------- lib/mcp/workflow-server.ts | 10 ++-- tests/unit/mcp-workflow-server.test.ts | 14 +++--- 7 files changed, 99 insertions(+), 66 deletions(-) create mode 100644 lib/mcp/internal-url.ts diff --git a/app/mcp/route.ts b/app/mcp/route.ts index 0a36d0049d..e13b2ecad2 100644 --- a/app/mcp/route.ts +++ b/app/mcp/route.ts @@ -3,6 +3,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { type ApiKeyAuthResult, authenticateApiKey } from "@/lib/api-key-auth"; import { McpEventStore } from "@/lib/mcp/event-store"; +import { getInternalApiBaseUrl } from "@/lib/mcp/internal-url"; import { logMcpEvent } from "@/lib/mcp/logging"; import { authenticateOAuthToken } from "@/lib/mcp/oauth-auth"; import { checkMcpRateLimit } from "@/lib/mcp/rate-limit"; @@ -177,7 +178,7 @@ function buildSession( organizationId: string, apiKeyId: string, scope: string | undefined, - baseUrl: string, + internalApiBaseUrl: string, authHeader: string ): BuiltSession { const eventStore = new McpEventStore(); @@ -197,7 +198,7 @@ function buildSession( enableJsonResponse: true, }); - const server = createMcpServer(baseUrl, authHeader, scope); + const server = createMcpServer(internalApiBaseUrl, authHeader, scope); const entry: SessionEntry = { transport, @@ -278,7 +279,7 @@ async function resolveSession( orgId: organizationId, }); - const baseUrl = getBaseUrl(request); + const internalApiBaseUrl = getInternalApiBaseUrl(); // Re-derive the auth header from the current request so tool calls in this // reconstructed session use the caller's credentials. const authHeader = request.headers.get("authorization") ?? ""; @@ -287,7 +288,7 @@ async function resolveSession( organizationId, result.payload.key, result.payload.scope, - baseUrl, + internalApiBaseUrl, authHeader ); @@ -483,14 +484,14 @@ export async function POST(request: Request): Promise { scope, }); - const baseUrl = getBaseUrl(request); + const internalApiBaseUrl = getInternalApiBaseUrl(); const authHeader = request.headers.get("authorization") ?? ""; const { transport, entry } = buildSession( newSessionId, organizationId, apiKeyId, scope, - baseUrl, + internalApiBaseUrl, authHeader ); diff --git a/app/mcp/w/[slug]/route.ts b/app/mcp/w/[slug]/route.ts index 7caf42bb55..bfa8c753d3 100644 --- a/app/mcp/w/[slug]/route.ts +++ b/app/mcp/w/[slug]/route.ts @@ -3,6 +3,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { type ApiKeyAuthResult, authenticateApiKey } from "@/lib/api-key-auth"; import { McpEventStore } from "@/lib/mcp/event-store"; +import { getInternalApiBaseUrl } from "@/lib/mcp/internal-url"; import { getWorkflowListing } from "@/lib/mcp/listing"; import { logMcpEvent } from "@/lib/mcp/logging"; import { authenticateOAuthToken } from "@/lib/mcp/oauth-auth"; @@ -157,7 +158,7 @@ function buildSession( organizationId: string, apiKeyId: string, scope: string | undefined, - baseUrl: string, + internalApiBaseUrl: string, authHeader: string, slug: string, listing: Parameters[0]["listing"] @@ -179,7 +180,7 @@ function buildSession( const server = createWorkflowMcpServer({ slug, listing, - baseUrl, + internalApiBaseUrl, authHeader, scope, }); @@ -261,14 +262,14 @@ async function resolveSession( orgId: organizationId, }); - const baseUrl = getBaseUrl(request); + const internalApiBaseUrl = getInternalApiBaseUrl(); const authHeader = request.headers.get("authorization") ?? ""; const { transport, entry } = buildSession( sessionId, organizationId, result.payload.key, result.payload.scope, - baseUrl, + internalApiBaseUrl, authHeader, slug, listing @@ -440,14 +441,14 @@ export async function POST( scope, }); - const baseUrl = getBaseUrl(request); + const internalApiBaseUrl = getInternalApiBaseUrl(); const authHeader = request.headers.get("authorization") ?? ""; const { transport, entry } = buildSession( newSessionId, organizationId, apiKeyId, scope, - baseUrl, + internalApiBaseUrl, authHeader, slug, listing diff --git a/lib/mcp/internal-url.ts b/lib/mcp/internal-url.ts new file mode 100644 index 0000000000..d017cea8e9 --- /dev/null +++ b/lib/mcp/internal-url.ts @@ -0,0 +1,23 @@ +const TRAILING_SLASH = /\/+$/; + +/** + * Base URL for the MCP server's server-to-server calls into this app's own + * /api/* routes (execution, workflow invocation, status polling, resources). + * + * This MUST stay on the in-pod loopback. The MCP route handlers and the /api + * routes run in the same Next.js process, so routing these internal calls + * through the public hostname (app.keeperhub.com) sends them out to the + * Cloudflare edge — whose managed WAF challenges the pod-to-edge request and + * 403s tool execution. Never substitute the public / OAuth base URL here; + * that one is for metadata documents the client must reach, not for our own + * internal fetches. + * + * INTERNAL_API_URL overrides the default for non-standard deployments. + */ +export function getInternalApiBaseUrl(): string { + const override = process.env.INTERNAL_API_URL; + if (override) { + return override.replace(TRAILING_SLASH, ""); + } + return `http://127.0.0.1:${process.env.PORT ?? "3000"}`; +} diff --git a/lib/mcp/server.ts b/lib/mcp/server.ts index fc9c35d84f..b268f52448 100644 --- a/lib/mcp/server.ts +++ b/lib/mcp/server.ts @@ -5,11 +5,11 @@ import { import { registerMetaTools, registerTools } from "./tools"; async function fetchJson( - baseUrl: string, + internalApiBaseUrl: string, authHeader: string, path: string ): Promise { - const response = await fetch(`${baseUrl}${path}`, { + const response = await fetch(`${internalApiBaseUrl}${path}`, { headers: { "Content-Type": "application/json", Authorization: authHeader, @@ -28,7 +28,7 @@ async function fetchJson( function registerResources( server: McpServer, - baseUrl: string, + internalApiBaseUrl: string, authHeader: string ): void { server.resource( @@ -36,7 +36,11 @@ function registerResources( "keeperhub://workflows", { description: "List all workflows for the authenticated organization" }, async (_uri) => { - const data = await fetchJson(baseUrl, authHeader, "/api/workflows"); + const data = await fetchJson( + internalApiBaseUrl, + authHeader, + "/api/workflows" + ); return { contents: [ { @@ -59,7 +63,11 @@ function registerResources( { description: "Get a specific workflow by ID" }, async (_uri, variables) => { const id = variables.id; - const data = await fetchJson(baseUrl, authHeader, `/api/workflows/${id}`); + const data = await fetchJson( + internalApiBaseUrl, + authHeader, + `/api/workflows/${id}` + ); return { contents: [ { @@ -74,7 +82,7 @@ function registerResources( } export function createMcpServer( - baseUrl: string, + internalApiBaseUrl: string, authHeader: string, scope?: string ): McpServer { @@ -83,9 +91,9 @@ export function createMcpServer( version: "1.0.0", }); - registerTools(server, baseUrl, authHeader, scope); - registerMetaTools(server, baseUrl, authHeader, scope); - registerResources(server, baseUrl, authHeader); + registerTools(server, internalApiBaseUrl, authHeader, scope); + registerMetaTools(server, internalApiBaseUrl, authHeader, scope); + registerResources(server, internalApiBaseUrl, authHeader); return server; } diff --git a/lib/mcp/tools.ts b/lib/mcp/tools.ts index 111e2d20c2..abe649407c 100644 --- a/lib/mcp/tools.ts +++ b/lib/mcp/tools.ts @@ -189,13 +189,13 @@ function buildPaymentRequiredHint( } async function callApi( - baseUrl: string, + internalApiBaseUrl: string, authHeader: string, path: string, method: string, body?: unknown ): Promise { - const url = `${baseUrl}${path}`; + const url = `${internalApiBaseUrl}${path}`; const headers: Record = { "Content-Type": "application/json", Authorization: authHeader, @@ -224,7 +224,7 @@ async function callApi( export function registerTools( server: McpServer, - baseUrl: string, + internalApiBaseUrl: string, authHeader: string, scope?: string ): void { @@ -257,7 +257,7 @@ export function registerTools( } const query = params.toString(); const path = `/api/workflows${query ? `?${query}` : ""}`; - const data = await callApi(baseUrl, authHeader, path, "GET"); + const data = await callApi(internalApiBaseUrl, authHeader, path, "GET"); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }; @@ -275,7 +275,7 @@ export function registerTools( withScopeCheck("get_workflow", scope, async (args) => withToolLogging("get_workflow", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/workflows/${args.workflowId}`, "GET" @@ -321,7 +321,7 @@ export function registerTools( withScopeCheck("create_workflow", scope, async (args) => withToolLogging("create_workflow", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, "/api/workflows/create", "POST", @@ -379,7 +379,7 @@ export function registerTools( withToolLogging("update_workflow", undefined, async () => { const { workflowId, ...body } = args; const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/workflows/${workflowId}`, "PATCH", @@ -402,7 +402,7 @@ export function registerTools( withScopeCheck("delete_workflow", scope, async (args) => withToolLogging("delete_workflow", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/workflows/${args.workflowId}`, "DELETE" @@ -432,7 +432,7 @@ export function registerTools( withScopeCheck("execute_workflow", scope, async (args) => withToolLogging("execute_workflow", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/workflow/${args.workflowId}/execute`, "POST", @@ -461,7 +461,7 @@ export function registerTools( withScopeCheck("get_execution_status", scope, async (args) => withToolLogging("get_execution_status", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/workflows/executions/${args.executionId}/status`, "GET" @@ -483,7 +483,7 @@ export function registerTools( withScopeCheck("get_execution_logs", scope, async (args) => withToolLogging("get_execution_logs", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/workflows/executions/${args.executionId}/logs`, "GET" @@ -521,7 +521,7 @@ export function registerTools( withScopeCheck("ai_generate_workflow", scope, async (args) => withToolLogging("ai_generate_workflow", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, "/api/ai/generate", "POST", @@ -571,7 +571,7 @@ export function registerTools( } const query = params.toString(); const path = `/api/mcp/schemas${query ? `?${query}` : ""}`; - const data = await callApi(baseUrl, authHeader, path, "GET"); + const data = await callApi(internalApiBaseUrl, authHeader, path, "GET"); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }; @@ -594,7 +594,7 @@ export function registerTools( withToolLogging("search_plugins", undefined, async () => { const params = new URLSearchParams({ category: args.category }); const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/mcp/schemas?${params.toString()}`, "GET" @@ -621,7 +621,7 @@ export function registerTools( withToolLogging("get_plugin", undefined, async () => { const params = new URLSearchParams({ category: args.pluginType }); const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/mcp/schemas?${params.toString()}`, "GET" @@ -641,7 +641,7 @@ export function registerTools( withScopeCheck("list_integrations", scope, async (_args) => withToolLogging("list_integrations", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, "/api/integrations", "GET" @@ -667,7 +667,7 @@ export function registerTools( withScopeCheck("get_wallet_integration", scope, async (args) => withToolLogging("get_wallet_integration", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/integrations/${args.integrationId}`, "GET" @@ -705,7 +705,7 @@ export function registerTools( } const query = params.toString(); const path = `/api/workflows/public${query ? `?${query}` : ""}`; - const data = await callApi(baseUrl, authHeader, path, "GET"); + const data = await callApi(internalApiBaseUrl, authHeader, path, "GET"); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }; @@ -723,7 +723,7 @@ export function registerTools( withScopeCheck("get_template", scope, async (args) => withToolLogging("get_template", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/workflows/${args.templateId}`, "GET" @@ -749,7 +749,7 @@ export function registerTools( withScopeCheck("deploy_template", scope, async (args) => withToolLogging("deploy_template", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/workflows/${args.templateId}/duplicate`, "POST", @@ -848,7 +848,7 @@ export function registerTools( withScopeCheck("execute_transfer", scope, async (args) => withToolLogging("execute_transfer", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, "/api/execute/transfer", "POST", @@ -908,7 +908,7 @@ export function registerTools( withScopeCheck("execute_contract_call", scope, async (args) => withToolLogging("execute_contract_call", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, "/api/execute/contract-call", "POST", @@ -977,7 +977,7 @@ export function registerTools( withScopeCheck("execute_check_and_execute", scope, async (args) => withToolLogging("execute_check_and_execute", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, "/api/execute/check-and-execute", "POST", @@ -1022,7 +1022,7 @@ export function registerTools( withScopeCheck("get_direct_execution_status", scope, async (args) => withToolLogging("get_direct_execution_status", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/execute/${args.execution_id}/status`, "GET" @@ -1041,7 +1041,7 @@ export function registerTools( export function registerMetaTools( server: McpServer, - baseUrl: string, + internalApiBaseUrl: string, authHeader: string, scope?: string ): void { @@ -1077,7 +1077,7 @@ export function registerMetaTools( params.set("includeChains", "false"); const path = `/api/mcp/schemas${params.toString() ? `?${params.toString()}` : ""}`; const data = (await callApi( - baseUrl, + internalApiBaseUrl, authHeader, path, "GET" @@ -1178,7 +1178,7 @@ export function registerMetaTools( const slug = parts.slice(1).join("/"); const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/execute/${integration}/${slug}`, "POST", @@ -1240,7 +1240,7 @@ export function registerMetaTools( } const query = params.toString(); const path = `/api/mcp/workflows${query ? `?${query}` : ""}`; - const data = await callApi(baseUrl, authHeader, path, "GET"); + const data = await callApi(internalApiBaseUrl, authHeader, path, "GET"); return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }; @@ -1267,7 +1267,7 @@ export function registerMetaTools( withToolLogging("call_workflow", undefined, async () => { try { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/mcp/workflows/${encodeURIComponent(args.slug)}/call`, "POST", @@ -1333,7 +1333,7 @@ export function registerMetaTools( withToolLogging("list_workflow", undefined, async () => { const { workflowId, ...metadata } = args; const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/mcp/workflows/${encodeURIComponent(workflowId)}/listing`, "POST", @@ -1359,7 +1359,7 @@ export function registerMetaTools( withScopeCheck("unlist_workflow", scope, async (args) => withToolLogging("unlist_workflow", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/mcp/workflows/${encodeURIComponent(args.workflowId)}/listing`, "DELETE" @@ -1413,7 +1413,7 @@ export function registerMetaTools( withToolLogging("update_workflow_listing", undefined, async () => { const { workflowId, ...patch } = args; const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/mcp/workflows/${encodeURIComponent(workflowId)}/listing`, "PATCH", @@ -1443,7 +1443,7 @@ export function registerMetaTools( withScopeCheck("get_workflow_listing", scope, async (args) => withToolLogging("get_workflow_listing", undefined, async () => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/mcp/workflows/${encodeURIComponent(args.slug)}/listing`, "GET" diff --git a/lib/mcp/workflow-server.ts b/lib/mcp/workflow-server.ts index 9ac8ee80cc..d48b98debd 100644 --- a/lib/mcp/workflow-server.ts +++ b/lib/mcp/workflow-server.ts @@ -5,13 +5,13 @@ import { z } from "zod"; type ApiResponse = Record; async function callApi( - baseUrl: string, + internalApiBaseUrl: string, authHeader: string, path: string, method: string, body?: unknown ): Promise { - const url = `${baseUrl}${path}`; + const url = `${internalApiBaseUrl}${path}`; const headers: Record = { "Content-Type": "application/json", Authorization: authHeader, @@ -53,7 +53,7 @@ export type WorkflowListing = { type CreateWorkflowMcpServerOptions = { slug: string; listing: WorkflowListing; - baseUrl: string; + internalApiBaseUrl: string; authHeader: string; scope?: string; }; @@ -113,7 +113,7 @@ function buildToolDescription(listing: WorkflowListing): string { export function createWorkflowMcpServer( options: CreateWorkflowMcpServerOptions ): McpServer { - const { slug, listing, baseUrl, authHeader } = options; + const { slug, listing, internalApiBaseUrl, authHeader } = options; const server = new McpServer({ name: `keeperhub-workflow-${slug}`, @@ -135,7 +135,7 @@ export function createWorkflowMcpServer( }, async (args) => { const data = await callApi( - baseUrl, + internalApiBaseUrl, authHeader, `/api/mcp/workflows/${encodeURIComponent(slug)}/call`, "POST", diff --git a/tests/unit/mcp-workflow-server.test.ts b/tests/unit/mcp-workflow-server.test.ts index 95accd7da4..d0d906fca5 100644 --- a/tests/unit/mcp-workflow-server.test.ts +++ b/tests/unit/mcp-workflow-server.test.ts @@ -48,7 +48,7 @@ describe("createWorkflowMcpServer", () => { createWorkflowMcpServer({ slug: "aave-position-monitor", listing: baseListing, - baseUrl: "http://localhost:3000", + internalApiBaseUrl: "http://localhost:3000", authHeader: "Bearer kh_test", }); @@ -60,7 +60,7 @@ describe("createWorkflowMcpServer", () => { createWorkflowMcpServer({ slug: "aave-position-monitor", listing: baseListing, - baseUrl: "http://localhost:3000", + internalApiBaseUrl: "http://localhost:3000", authHeader: "Bearer kh_test", }); @@ -72,7 +72,7 @@ describe("createWorkflowMcpServer", () => { createWorkflowMcpServer({ slug: "aave-position-monitor", listing: baseListing, - baseUrl: "http://localhost:3000", + internalApiBaseUrl: "http://localhost:3000", authHeader: "Bearer kh_test", }); @@ -85,7 +85,7 @@ describe("createWorkflowMcpServer", () => { createWorkflowMcpServer({ slug: "aave-position-monitor", listing: baseListing, - baseUrl: "http://localhost:3000", + internalApiBaseUrl: "http://localhost:3000", authHeader: "Bearer kh_test", }); @@ -103,7 +103,7 @@ describe("createWorkflowMcpServer", () => { createWorkflowMcpServer({ slug: "aave-position-monitor", listing: paidListing, - baseUrl: "http://localhost:3000", + internalApiBaseUrl: "http://localhost:3000", authHeader: "Bearer kh_test", }); @@ -123,7 +123,7 @@ describe("createWorkflowMcpServer", () => { createWorkflowMcpServer({ slug: "aave-position-monitor", listing: baseListing, - baseUrl: "http://localhost:3000", + internalApiBaseUrl: "http://localhost:3000", authHeader: "Bearer kh_test", }); @@ -152,7 +152,7 @@ describe("createWorkflowMcpServer", () => { createWorkflowMcpServer({ slug: "aave-position-monitor", listing: baseListing, - baseUrl: "http://localhost:3000", + internalApiBaseUrl: "http://localhost:3000", authHeader: "Bearer kh_test", });