Bound shared MCP container lifecycle

This commit is contained in:
2026-07-24 13:03:09 -07:00
parent 8de9658b8c
commit 6177a995d5
17 changed files with 1351 additions and 117 deletions

View File

@@ -2,9 +2,11 @@ FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a596
ARG MCP_WEB_SEARCH_VERSION=1.3.0
ARG MCP_WEB_SEARCH_MAX_BYTES=52428800
ARG MCP_PROXY_VERSION=0.12.0
COPY patch-mcp-web-search.mjs /tmp/patch-mcp-web-search.mjs
COPY overrides/bing.js /tmp/context-kit-bing-provider.js
COPY --chmod=0444 mcp-probe.mjs http-entrypoint.mjs /usr/local/lib/context-kit/
# Chromium intentionally tracks Debian security updates inside the pinned base
# image family; Bing's browser path is more likely to break with stale Chromium
@@ -14,8 +16,13 @@ RUN apt-get update \
ca-certificates \
chromium \
fonts-liberation \
python3-venv \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/mcp-proxy \
&& /opt/mcp-proxy/bin/pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" \
&& /opt/mcp-proxy/bin/mcp-proxy --version
RUN npm install -g "@zhafron/mcp-web-search@${MCP_WEB_SEARCH_VERSION}" \
&& cp /tmp/context-kit-bing-provider.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/bing.js \
&& node /tmp/patch-mcp-web-search.mjs \
@@ -28,9 +35,12 @@ ENV CHROME_PATH=/usr/bin/chromium \
HTTP_TIMEOUT=15000 \
MAX_BYTES=${MCP_WEB_SEARCH_MAX_BYTES} \
MAX_RESULTS=10 \
PATH=/opt/mcp-proxy/bin:$PATH \
SEARXNG_URL=http://searxng:8080 \
XDG_CACHE_HOME=/tmp/.cache
USER node
ENTRYPOINT ["mcp-web-search"]
EXPOSE 8000
ENTRYPOINT ["node", "/usr/local/lib/context-kit/http-entrypoint.mjs"]

View File

@@ -0,0 +1,183 @@
import http from "node:http";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { probeMcp } from "./mcp-probe.mjs";
const defaultUpstream = "http://127.0.0.1:8001";
export const mcpProxyArguments = Object.freeze([
"--host", "127.0.0.1",
"--port", "8001",
"--stateless",
"--pass-environment",
"--",
"mcp-web-search"
]);
const hopByHopHeaders = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade"
]);
export function hostAllowed(host) {
if (!host) return false;
const normalized = host.toLowerCase();
return /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(normalized)
|| /^\[::1\](:\d+)?$/.test(normalized)
|| normalized === "web-search-mcp:8000";
}
function copyRequestHeaders(headers, upstreamHost) {
const copied = { ...headers, host: upstreamHost };
for (const name of hopByHopHeaders) delete copied[name];
delete copied.origin;
return copied;
}
function copyResponseHeaders(headers) {
const copied = {};
for (const [name, value] of Object.entries(headers)) {
if (!hopByHopHeaders.has(name) && !name.startsWith("access-control-")) copied[name] = value;
}
return copied;
}
export function createSecureMcpServer({ upstream = defaultUpstream, probe = probeMcp } = {}) {
const target = new URL(upstream);
return http.createServer(async (request, response) => {
if (!hostAllowed(request.headers.host)) {
response.writeHead(421, { "Content-Type": "text/plain" });
response.end("Invalid Host header");
return;
}
if (request.headers.origin !== undefined) {
response.writeHead(403, { "Content-Type": "text/plain" });
response.end("Invalid Origin header");
return;
}
if (request.url === "/healthz") {
try {
await probe(`${upstream}/mcp`);
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("ok");
} catch (error) {
response.writeHead(503, { "Content-Type": "text/plain" });
response.end(`backend unavailable: ${error.message}`);
}
return;
}
if (!request.url?.startsWith("/")) {
response.writeHead(400, { "Content-Type": "text/plain" });
response.end("Invalid request target");
return;
}
const upstreamRequest = http.request({
hostname: target.hostname,
port: target.port,
method: request.method,
path: request.url,
headers: copyRequestHeaders(request.headers, target.host)
}, upstreamResponse => {
response.writeHead(
upstreamResponse.statusCode || 502,
copyResponseHeaders(upstreamResponse.headers)
);
upstreamResponse.pipe(response);
});
upstreamRequest.on("error", error => {
if (!response.headersSent) response.writeHead(502, { "Content-Type": "text/plain" });
response.end(`backend unavailable: ${error.message}`);
});
request.pipe(upstreamRequest);
});
}
export function superviseBackend({ probe, intervalMs = 10000, onFailure }) {
let stopped = false;
let timer;
const check = async () => {
if (stopped) return;
try {
await probe();
timer = setTimeout(check, intervalMs);
} catch (error) {
stopped = true;
onFailure(error);
}
};
timer = setTimeout(check, intervalMs);
return () => {
stopped = true;
clearTimeout(timer);
};
}
async function waitForBackend(child, url) {
let lastError;
for (let attempt = 0; attempt < 60; attempt += 1) {
if (child.exitCode !== null) throw new Error(`mcp-proxy exited during startup (${child.exitCode})`);
try {
await probeMcp(url, { timeoutMs: 1000 });
return;
} catch (error) {
lastError = error;
await delay(250);
}
}
throw new Error(`web-search backend did not become ready: ${lastError?.message}`);
}
async function main() {
const upstreamMcp = `${defaultUpstream}/mcp`;
const child = spawn("mcp-proxy", mcpProxyArguments, { stdio: ["ignore", "inherit", "inherit"] });
let server;
let stopSupervisor = () => {};
let shuttingDown = false;
const shutdown = async (code, reason) => {
if (shuttingDown) return;
shuttingDown = true;
if (reason) console.error(`web-search-mcp: ${reason}`);
stopSupervisor();
server?.close();
server?.closeAllConnections();
if (child.exitCode === null) {
child.kill("SIGTERM");
await Promise.race([once(child, "exit"), delay(3000)]).catch(() => {});
if (child.exitCode === null) child.kill("SIGKILL");
}
process.exitCode = code;
};
child.once("exit", (code, signal) => {
if (!shuttingDown) void shutdown(1, `mcp-proxy exited (code=${code}, signal=${signal})`);
});
process.once("SIGINT", () => void shutdown(0));
process.once("SIGTERM", () => void shutdown(0));
try {
await waitForBackend(child, upstreamMcp);
server = createSecureMcpServer();
server.listen(8000, "0.0.0.0");
await once(server, "listening");
stopSupervisor = superviseBackend({
probe: () => probeMcp(upstreamMcp),
onFailure: error => void shutdown(1, `backend probe failed: ${error.message}`)
});
} catch (error) {
await shutdown(1, error.message);
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await main();

View File

@@ -0,0 +1,53 @@
import { fileURLToPath } from "node:url";
const protocolVersion = "2024-11-05";
const expectedTools = ["fetch_url", "search_web"];
async function rpc(url, id, method, params = {}, timeoutMs = 5000) {
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
"MCP-Protocol-Version": protocolVersion
},
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
signal: AbortSignal.timeout(timeoutMs)
});
const text = await response.text();
if (!response.ok) throw new Error(`${method} returned HTTP ${response.status}: ${text.slice(0, 300)}`);
let payload;
if (response.headers.get("content-type")?.includes("text/event-stream")) {
const data = text.split("\n").find(line => line.startsWith("data: "))?.slice(6);
if (!data) throw new Error(`${method} returned an empty event stream`);
payload = JSON.parse(data);
} else {
payload = JSON.parse(text);
}
if (payload.error) throw new Error(`${method} returned ${JSON.stringify(payload.error)}`);
return payload.result;
}
export async function probeMcp(url, { timeoutMs = 5000 } = {}) {
const initialized = await rpc(url, 1, "initialize", {
protocolVersion,
capabilities: {},
clientInfo: { name: "context-kit-health", version: "1" }
}, timeoutMs);
if (!initialized?.serverInfo?.name) throw new Error("initialize response omitted serverInfo");
const listed = await rpc(url, 2, "tools/list", {}, timeoutMs);
const names = new Set((listed?.tools || []).map(tool => tool.name));
for (const name of expectedTools) {
if (!names.has(name)) throw new Error(`tools/list omitted ${name}`);
}
return Array.from(names).sort();
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const url = process.argv[2];
if (!url) throw new Error("usage: node mcp-probe.mjs <streamable-http-url>");
const tools = await probeMcp(url);
console.log(JSON.stringify({ tools }));
}