From 291703ecc3d5a33152ace07b0f6693df43fa1e00 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Tue, 6 Oct 2026 17:27:28 +0100 Subject: [PATCH] Expose native MCP endpoint alongside mcpo Run supergateway on port 8080 (streamable HTTP at /mcp) next to mcpo on 8000, each spawning its own webfetch-mcp child. Supervise both under tini. Co-Authored-By: Claude Sonnet 5.5 --- Dockerfile | 17 +++++++++++-- README.md | 57 ++++++++++++++++++++++++++++++++--------- entrypoint.sh | 70 +++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 119 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index 48f79c3..4d42369 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,12 @@ ARG MCPO_VERSION=0.0.20 # release's uv.lock for its actual tested "mcp" version and update this too. ARG MCP_SDK_VERSION=1.17.0 +# Pinned supergateway release (npm). It serves a stdio MCP server as a native +# streamable-HTTP endpoint. Bump by checking the new version's CLI flags still +# match what entrypoint.sh assumes (--stdio/--outputTransport/--port/ +# --healthEndpoint). +ARG SUPERGATEWAY_VERSION=4.1.0 + FROM python:3.12-slim-bookworm AS vendor ARG WEBFETCH_MCP_COMMIT RUN apt-get update && apt-get install -y --no-install-recommends git \ @@ -31,19 +37,26 @@ RUN mkdir /vendored \ FROM python:3.12-slim-bookworm ARG MCPO_VERSION ARG MCP_SDK_VERSION +ARG SUPERGATEWAY_VERSION +# tini is PID 1: entrypoint.sh supervises two long-running processes, so +# something needs to reap zombies and deliver signals properly. # Node.js 22, matching the version mcpo's own official image is built and # tested against (webfetch-mcp only requires >=18). RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ ca-certificates \ gnupg \ + tini \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir "mcpo==${MCPO_VERSION}" "mcp==${MCP_SDK_VERSION}" +RUN npm install -g --omit=dev "supergateway@${SUPERGATEWAY_VERSION}" \ + && npm cache clean --force + COPY --from=vendor /vendored /app/webfetch-mcp WORKDIR /app/webfetch-mcp RUN npm ci --omit=dev @@ -51,5 +64,5 @@ RUN npm ci --omit=dev COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 8000 -ENTRYPOINT ["/entrypoint.sh"] +EXPOSE 8000 8080 +ENTRYPOINT ["tini", "--", "/entrypoint.sh"] diff --git a/README.md b/README.md index 97a5b3f..7497574 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,17 @@ # webfetch-mcpo-docker A single Docker image exposing [`webfetch-mcp`](https://code.aneur.in/mirrors/webfetch-mcp)'s -`web_search` and `web_fetch` tools over HTTP/OpenAPI, via [`mcpo`](https://github.com/open-webui/mcpo). +`web_search` and `web_fetch` tools in two ways at once: -`webfetch-mcp` is a stdio-only MCP server; `mcpo` exposes a wrapped MCP server over -HTTP by spawning it as a local child process. Since that spawn happens in-process -(not over the network) and `webfetch-mcp` has no HTTP/SSE mode of its own, both have -to live in one image — that's what this repo builds. +| Port | Protocol | For | +|---|---|---| +| `8000` | OpenAPI, via [`mcpo`](https://github.com/open-webui/mcpo) | Open WebUI | +| `8080` | Native MCP (streamable HTTP) at `/mcp`, via [`supergateway`](https://github.com/supercorp-ai/supergateway) | Coding agents (opencode etc.) | + +`webfetch-mcp` is a stdio-only MCP server with no HTTP mode of its own. `mcpo` and +`supergateway` each expose a wrapped stdio server over HTTP by spawning it as a local +child process, so they have to live in the same image as `webfetch-mcp`. Each spawns +its own `node server.mjs`. ## Build @@ -17,7 +22,7 @@ docker build -t webfetch-mcpo-docker . ## Run ```sh -docker run -p 8000:8000 \ +docker run -p 8000:8000 -p 8080:8080 \ -e MCPO_API_KEY=some-secret \ -e SEARXNG_BASE=http://searxng.example.internal:8080 \ webfetch-mcpo-docker @@ -28,24 +33,48 @@ served without authentication even when `MCPO_API_KEY` is set — only the gener tool endpoints (`/web_search`, `/web_fetch`) require it, as `Authorization: Bearer `. +### opencode (or any MCP client) + +In `opencode.json`: + +```json +{ + "mcp": { + "webfetch": { "type": "remote", "url": "http://:8080/mcp" } + } +} +``` + +The `:8080` endpoint has no authentication; `MCPO_API_KEY` applies only to `:8000`. +`/healthz` on `:8080` returns `ok`. + ## Environment variables | Variable | Effect | |---|---| | `MCPO_API_KEY` | If set, tool endpoints require `Authorization: Bearer `. If unset, `mcpo` serves unauthenticated (its own default behavior). | | `MCPO_PORT` | Port `mcpo` listens on inside the container. Default `8000`. | +| `MCP_PORT` | Port the native MCP endpoint listens on. Default `8080`. | +| `ENABLE_MCPO` / `ENABLE_MCP` | Set either to `false` to run only the other side. Both default `true`. | | `SEARXNG_BASE` | Passed through to `webfetch-mcp`; its own default (`http://localhost:8080`) applies if unset. | | `DEBUG` | Passed through to `webfetch-mcp`. `"true"` enables debug logging. | | `DETAILED_LOG` | Passed through to `webfetch-mcp`. Defaults on; set to `"false"` to disable its detailed log file. | -No explicit plumbing is needed to get these into `webfetch-mcp`: `mcpo` builds the -spawned child process's environment as the union of its own process environment and -any per-server overrides, so anything set on the container's environment is -inherited by the `node server.mjs` child automatically. (Verified against `mcpo`'s +No explicit plumbing is needed to get these into `webfetch-mcp`: both `mcpo` and +`supergateway` start the child with the container's environment inherited, so +anything set on the container's environment reaches `node server.mjs` automatically. (Verified against `mcpo`'s `MCPConnectionManager` source, not assumed.) +## Process supervision + +`entrypoint.sh` runs both servers under `tini`. If either one exits, the other is +stopped and the container exits non-zero, so the orchestrator restarts the whole +thing rather than leaving it half up. + ## Known characteristics +- **The `:8080` endpoint is unauthenticated.** Intended for a trusted LAN only. + - **No URL filtering / SSRF protection.** `webfetch-mcp`'s `web_fetch` tool fetches whatever URL it's given, including private/internal addresses. This is a known, accepted characteristic of `webfetch-mcp` itself — out of scope for this image to @@ -64,9 +93,13 @@ inherited by the `node server.mjs` child automatically. (Verified against `mcpo` GitHub. - `mcpo`: pinned via `ARG MCPO_VERSION` in the `Dockerfile`, installed from PyPI. -Bump either by updating the corresponding `ARG` default in the `Dockerfile`. When +- `supergateway`: pinned via `ARG SUPERGATEWAY_VERSION` in the `Dockerfile`, installed + from npm. + +Bump any of them by updating the corresponding `ARG` default in the `Dockerfile`. When bumping `mcpo`, double check its CLI flags still match what `entrypoint.sh` assumes -(`--host`, `--port`, `--api-key`, `--`). +(`--host`, `--port`, `--api-key`, `--`); for `supergateway`, `--stdio`, +`--outputTransport`, `--port` and `--healthEndpoint`. ## CI diff --git a/entrypoint.sh b/entrypoint.sh index f12c44e..f84ec83 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,20 +1,68 @@ #!/bin/sh set -eu +# Runs up to two servers in one container, each spawning its own stdio +# `node server.mjs` child: +# +# - mcpo (OpenAPI, for Open WebUI) on $MCPO_PORT. +# +# - supergateway (native streamable-HTTP MCP, for coding clients) on +# $MCP_PORT, path /mcp. +# # mcpo's --api-key is a CLI flag, not something it reads from its own -# environment, but the actual value must come from the container's -# environment rather than being baked into the image. This script builds -# the mcpo command line at container start so that can happen. +# environment, so the command line is built here at container start. # # webfetch-mcp reads SEARXNG_BASE / DEBUG / DETAILED_LOG from its own -# process environment. No passthrough is needed here: mcpo builds the -# spawned child's environment as {**os.environ, **extra}, so anything set -# on this container's environment reaches webfetch-mcp automatically. +# process environment. No passthrough is needed here: both mcpo and +# supergateway start the child with this container's environment inherited. -PORT="${MCPO_PORT:-8000}" +MCPO_PORT="${MCPO_PORT:-8000}" +MCP_PORT="${MCP_PORT:-8080}" +ENABLE_MCPO="${ENABLE_MCPO:-true}" +ENABLE_MCP="${ENABLE_MCP:-true}" -if [ -n "${MCPO_API_KEY:-}" ]; then - exec mcpo --host 0.0.0.0 --port "$PORT" --api-key "$MCPO_API_KEY" -- node /app/webfetch-mcp/server.mjs -else - exec mcpo --host 0.0.0.0 --port "$PORT" -- node /app/webfetch-mcp/server.mjs +pids="" + +shutdown() { + # shellcheck disable=SC2086 + [ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true +} +trap 'shutdown; wait; exit 143' TERM INT + +if [ "$ENABLE_MCP" = "true" ]; then + supergateway --stdio "node /app/webfetch-mcp/server.mjs" \ + --outputTransport streamableHttp --port "$MCP_PORT" \ + --healthEndpoint /healthz & + pids="$pids $!" fi + +if [ "$ENABLE_MCPO" = "true" ]; then + if [ -n "${MCPO_API_KEY:-}" ]; then + mcpo --host 0.0.0.0 --port "$MCPO_PORT" --api-key "$MCPO_API_KEY" -- node /app/webfetch-mcp/server.mjs & + else + mcpo --host 0.0.0.0 --port "$MCPO_PORT" -- node /app/webfetch-mcp/server.mjs & + fi + pids="$pids $!" +fi + +if [ -z "$pids" ]; then + echo "Both ENABLE_MCPO and ENABLE_MCP are false; nothing to run." >&2 + exit 1 +fi + +# Exit as soon as either server dies, taking the other down with it, so the +# orchestrator restarts the whole container rather than leaving it half-up. +# POSIX sh has no `wait -n`, so poll instead. +while :; do + for pid in $pids; do + if ! kill -0 "$pid" 2>/dev/null; then + status=0 + wait "$pid" || status=$? + echo "Process $pid exited (status $status); stopping container." >&2 + shutdown + wait || true + exit "$(( status == 0 ? 1 : status ))" + fi + done + sleep 1 +done