Bound shared MCP container lifecycle
This commit is contained in:
@@ -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"]
|
||||
|
||||
183
docker/web-search/http-entrypoint.mjs
Normal file
183
docker/web-search/http-entrypoint.mjs
Normal 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();
|
||||
53
docker/web-search/mcp-probe.mjs
Normal file
53
docker/web-search/mcp-probe.mjs
Normal 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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user