Expose Engine MCP through the shared agent gateway

This commit is contained in:
DCCONSTRUCTIONS 2026-07-23 13:53:56 +03:00
parent 5e79a53e13
commit b30f1c3abe
4 changed files with 99 additions and 0 deletions

View File

@ -30,6 +30,7 @@ services:
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: ${NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS:-43200}
NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080}
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090}
NODEDC_ENGINE_INTERNAL_URL: ${NODEDC_ENGINE_INTERNAL_URL:-http://172.22.0.222:3001}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN}
depends_on:
postgres:

View File

@ -6,6 +6,7 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
import { ToolExecutionInputError } from "./mcp/tool-runtime.js";
import { AgentsRepository } from "./repositories/agents.js";
import { registerAgentRoutes } from "./routes/agents.js";
import { registerEngineGatewayRoutes } from "./routes/engine.js";
import { registerHealthRoutes } from "./routes/health.js";
import { registerInstallerRoutes } from "./routes/install.js";
import { registerMcpRoutes } from "./routes/mcp.js";
@ -138,6 +139,10 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN,
aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS,
});
await registerEngineGatewayRoutes(app, {
engineInternalUrl: config.NODEDC_ENGINE_INTERNAL_URL,
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
});
await registerToolRoutes(app, { agentsRepository, taskerClient });
await registerMcpRoutes(app, { agentsRepository, taskerClient });

View File

@ -15,6 +15,7 @@ const configSchema = z.object({
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: z.coerce.number().int().min(300).max(86_400).default(43_200),
NODEDC_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"),
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
NODEDC_ENGINE_INTERNAL_URL: z.string().url().default("http://172.22.0.222:3001"),
NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(),
DATABASE_URL: optionalUrl,
});

92
src/routes/engine.ts Normal file
View File

@ -0,0 +1,92 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
type EngineGatewayRouteDeps = {
engineInternalUrl: string;
publicUrl: string;
};
const RESPONSE_HEADERS = [
"cache-control",
"content-disposition",
"mcp-protocol-version",
"mcp-session-id",
"vary",
] as const;
/**
* Fixed Engine namespace inside the public Agent Gateway.
*
* This is not a generic reverse proxy. Engine keeps ownership of its setup
* codes, device tokens, L1 -> L2 grants, and tool authorization; the public
* gateway only forwards the explicitly registered bootstrap and MCP routes.
*/
export async function registerEngineGatewayRoutes(app: FastifyInstance, deps: EngineGatewayRouteDeps): Promise<void> {
app.get("/engine/healthz", async (request, reply) =>
proxyEngine(request, reply, deps, "/health", { binary: false })
);
app.get("/engine/install/nodedc-engine-codex-agent-setup.tgz", async (request, reply) =>
proxyEngine(request, reply, deps, "/api/engine-agent-api/install/engine-codex-agent.tgz", { binary: true })
);
app.post("/engine/api/v1/setup-codes/redeem", async (request, reply) =>
proxyEngine(request, reply, deps, "/api/engine-agent-api/setup/redeem")
);
app.get("/engine/doctor", async (request, reply) =>
proxyEngine(request, reply, deps, "/api/engine-agent-api/doctor")
);
app.get("/engine/mcp", async (request, reply) =>
proxyEngine(request, reply, deps, "/api/engine-agent-mcp")
);
app.post("/engine/mcp", async (request, reply) =>
proxyEngine(request, reply, deps, "/api/engine-agent-mcp")
);
}
async function proxyEngine(
request: FastifyRequest,
reply: FastifyReply,
deps: EngineGatewayRouteDeps,
pathname: string,
{ binary = false }: { binary?: boolean } = {}
): Promise<FastifyReply> {
const method = request.method.toUpperCase();
const hasBody = method !== "GET" && method !== "HEAD";
const publicUrl = new URL(deps.publicUrl);
const headers = new Headers();
headers.set("Accept", readHeader(request.headers.accept) || (binary ? "application/octet-stream" : "application/json"));
headers.set("X-Forwarded-Host", publicUrl.host);
headers.set("X-Forwarded-Proto", publicUrl.protocol.replace(/:$/, "") || "https");
for (const name of ["authorization", "content-type", "mcp-protocol-version", "mcp-session-id", "last-event-id"] as const) {
const value = readHeader(request.headers[name]);
if (value) headers.set(name, value);
}
let upstream: Response;
try {
upstream = await fetch(new URL(pathname, deps.engineInternalUrl), {
method,
headers,
redirect: "manual",
body: hasBody ? JSON.stringify(request.body ?? {}) : undefined,
signal: AbortSignal.timeout(30_000),
});
} catch {
return reply.status(502).send({ ok: false, error: "engine_gateway_unavailable" });
}
reply.status(upstream.status);
reply.type(upstream.headers.get("content-type") || (binary ? "application/octet-stream" : "application/json; charset=utf-8"));
for (const header of RESPONSE_HEADERS) {
const value = upstream.headers.get(header);
if (value) reply.header(header, value);
}
if (binary) {
return reply.send(Buffer.from(await upstream.arrayBuffer()));
}
return reply.send(await upstream.text());
}
function readHeader(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || "" : value || "";
}