Build a Docker image that bundles mcpo + webfetch-mcp as an HTTP/OpenAPI service #1

Closed
opened 2026-09-26 13:32:22 +00:00 by claude · 0 comments
Member

Goal

Produce a single Docker image that, when run, exposes an HTTP/OpenAPI endpoint for a web-search + page-fetch MCP tool — suitable for any client that speaks OpenAPI-style HTTP tool calls (e.g. a chat UI's "external tools" integration).

Background: the two pieces being combined

webfetch-mcp is a single-file Node.js MCP (Model Context Protocol) server (server.mjs) that speaks the MCP protocol over stdio (stdin/stdout), not HTTP. It provides two tools:

  • web_search — queries a SearXNG instance's JSON search API
  • web_fetch — fetches an arbitrary URL and extracts readable content via Mozilla Readability

It reads configuration from environment variables at startup:

  • SEARXNG_BASE — base URL of a SearXNG instance (default http://localhost:8080)
  • DEBUG — "true" enables debug logging (default off)
  • DETAILED_LOG — enables a detailed log file (default on)

Its dependencies (per its package.json): @modelcontextprotocol/sdk, jsdom, @mozilla/readability. Requires Node.js 18+. It has no Docker image and is not published to npm — it's meant to be cloned and run with node server.mjs.

mcpo is a Python CLI tool (pip install mcpo) that takes a stdio MCP server command as an argument, launches it as a child process, and exposes an HTTP server in front of it that translates HTTP/OpenAPI requests into MCP stdio calls and back. Its invocation shape:

mcpo --port <PORT> [--api-key <KEY>] -- <stdio command and args>

e.g. mcpo --port 8000 --api-key "secret" -- node server.mjs. With no --api-key, it serves with no auth. It serves an OpenAPI spec (and interactive docs at /docs) once running.

The constraint driving this issue: mcpo works by spawning its wrapped MCP server as a local child process over stdin/stdout — not a network call — and webfetch-mcp only speaks stdio (no HTTP/SSE mode). So mcpo and webfetch-mcp cannot run as two independent, separately-deployed containers talking to each other; whatever runs has to have both in the same container's process tree. Neither project ships an image containing both a Node.js runtime (for webfetch-mcp) and mcpo (Python) wired together — that's what this image needs to be.

What the image needs to do

  1. Bundle both runtimes and the vendored source.

    • Include Python + mcpo (pip install mcpo, or an equivalent virtualenv setup), and Node.js 18+.
    • Vendor webfetch-mcp's source directly into the image (clone or copy server.mjs + package.json + package-lock.json at a pinned commit or tag, not a moving main/latest — this is a small, single-maintainer project, so pin to a specific known-good revision and make that revision easy to find/bump later, e.g. as a Dockerfile build arg or a comment noting the pinned SHA). Run npm ci --omit=dev (or npm install --production) to install its three dependencies at build time — don't fetch them at container start.
    • Two reasonable build strategies, either is fine — pick whichever gives the smaller/cleaner image:
      • Start FROM a Node base image, then install Python/pip and mcpo on top.
      • Start FROM a Python base image, then install Node.js on top.
    • A multi-stage build that vendors webfetch-mcp in one stage and copies it (COPY --from=) into the final runtime stage is also fine if it keeps the Dockerfile cleaner — but the shipped artifact is still one image containing both.
  2. Wire the two together via a runtime entrypoint script, not a fixed CMD array.
    This is the part that needs the most care, so being explicit:

    • mcpo's --api-key is a CLI flag, not something the process reads from its own environment — but the actual key value needs to be supplied when the container is started (docker run -e ... / an orchestrator's environment: block), not baked into the image. A Dockerfile's CMD ["mcpo", "--port", "8000", "--api-key", "???", "--", "node", "server.mjs"] can't do this, because there's no way to splice an environment variable into a fixed exec-form array.
    • Solution: make the image's entrypoint a small shell script (ENTRYPOINT ["/entrypoint.sh"]) that reads environment variables at container start and constructs the mcpo command line, e.g.:
      #!/bin/sh
      set -eu
      PORT="${MCPO_PORT:-8000}"
      if [ -n "${MCPO_API_KEY:-}" ]; then
        exec mcpo --port "$PORT" --api-key "$MCPO_API_KEY" -- node /app/server.mjs
      else
        exec mcpo --port "$PORT" -- node /app/server.mjs
      fi
      
      (adjust the vendored path as needed). No API key set → mcpo runs with no auth, matching its own default behavior — don't invent a different default.
    • SEARXNG_BASE (and DEBUG/DETAILED_LOG if useful) should reach webfetch-mcp unmodified. Since mcpo spawns node server.mjs as a child process, environment variables set on the container process are expected to be inherited by that child automatically (standard subprocess-spawning behavior) — verify this holds for mcpo's specific process-spawning code (i.e. that it doesn't strip/reset the environment when launching its child) rather than assuming it. If it doesn't inherit automatically, the entrypoint script will need to pass it through explicitly.
  3. Environment variable contract the image should honor (document these in the repo's README):

    Variable Effect
    MCPO_API_KEY If set, passed as mcpo --api-key. If unset, mcpo runs unauthenticated.
    MCPO_PORT Port mcpo listens on inside the container. Default 8000.
    SEARXNG_BASE Passed through to webfetch-mcp; its own default (http://localhost:8080) applies if unset.
    DEBUG Passed through to webfetch-mcp (optional).

    (Naming above is a suggestion, not a hard requirement — pick clear names and document whatever you land on.)

  4. Expose the port mcpo listens on (EXPOSE 8000 or matching whatever MCPO_PORT defaults to).

  5. CI: add a Gitea Actions workflow that builds the image on push to main and pushes it to this Gitea instance's container registry (code.aneur.in/<org>/<this-repo-name>), authenticating via a repo secret holding a personal access token with package-write scope (the auto-injected default token does not have package-registry permissions on this Gitea instance — a manually-created repo secret is required). Tag as latest.

Non-goals

  • No URL filtering / SSRF protection. webfetch-mcp fetches whatever URL it's given, including private/internal addresses. Building a filter for this is explicitly out of scope for this issue — document it as a known characteristic in the README, but don't try to fix it here.
  • No changes to webfetch-mcp's own logic — vendor it as-is at a pinned revision.
  • No TLS termination — this image serves plain HTTP; anything in front of it (reverse proxy, firewall) is somebody else's concern.

Acceptance criteria

  • docker build produces an image.
  • docker run -p 8000:8000 -e MCPO_API_KEY=testkey -e SEARXNG_BASE=http://example.invalid:8080 <image> starts cleanly and logs show mcpo up and webfetch-mcp spawned as its child.
  • curl http://localhost:8000/docs (or /openapi.json) returns mcpo's generated API docs/spec, showing web_search and web_fetch as available operations.
  • A request to one of those endpoints without the correct API-key credential is rejected; with the correct testkey it's accepted and reaches webfetch-mcp (a web_fetch call against a real, reachable URL should return extracted content — a web_search call needs an actual SearXNG instance to fully verify, so a mocked/expected-error response is acceptable for that half if no SearXNG instance is available during testing).
  • README documents: how to build, the environment variable contract above, and the accepted no-URL-filtering caveat.
## Goal Produce a single Docker image that, when run, exposes an HTTP/OpenAPI endpoint for a web-search + page-fetch MCP tool — suitable for any client that speaks OpenAPI-style HTTP tool calls (e.g. a chat UI's "external tools" integration). ## Background: the two pieces being combined **[`webfetch-mcp`](https://github.com/manooll/webfetch-mcp)** is a single-file Node.js MCP (Model Context Protocol) server (`server.mjs`) that speaks the MCP protocol over **stdio** (stdin/stdout), not HTTP. It provides two tools: - `web_search` — queries a [SearXNG](https://docs.searxng.org/) instance's JSON search API - `web_fetch` — fetches an arbitrary URL and extracts readable content via Mozilla Readability It reads configuration from environment variables at startup: - `SEARXNG_BASE` — base URL of a SearXNG instance (default `http://localhost:8080`) - `DEBUG` — `"true"` enables debug logging (default off) - `DETAILED_LOG` — enables a detailed log file (default on) Its dependencies (per its `package.json`): `@modelcontextprotocol/sdk`, `jsdom`, `@mozilla/readability`. Requires Node.js 18+. It has no Docker image and is not published to npm — it's meant to be cloned and run with `node server.mjs`. **[`mcpo`](https://github.com/open-webui/mcpo)** is a Python CLI tool (`pip install mcpo`) that takes a stdio MCP server command as an argument, launches it as a **child process**, and exposes an HTTP server in front of it that translates HTTP/OpenAPI requests into MCP stdio calls and back. Its invocation shape: ``` mcpo --port <PORT> [--api-key <KEY>] -- <stdio command and args> ``` e.g. `mcpo --port 8000 --api-key "secret" -- node server.mjs`. With no `--api-key`, it serves with no auth. It serves an OpenAPI spec (and interactive docs at `/docs`) once running. **The constraint driving this issue**: `mcpo` works by spawning its wrapped MCP server as a **local child process** over stdin/stdout — not a network call — and `webfetch-mcp` only speaks stdio (no HTTP/SSE mode). So `mcpo` and `webfetch-mcp` cannot run as two independent, separately-deployed containers talking to each other; whatever runs has to have both in the same container's process tree. Neither project ships an image containing both a Node.js runtime (for `webfetch-mcp`) and `mcpo` (Python) wired together — that's what this image needs to be. ## What the image needs to do 1. **Bundle both runtimes and the vendored source.** - Include Python + `mcpo` (`pip install mcpo`, or an equivalent virtualenv setup), and Node.js 18+. - Vendor `webfetch-mcp`'s source directly into the image (clone or copy `server.mjs` + `package.json` + `package-lock.json` at a **pinned commit or tag**, not a moving `main`/`latest` — this is a small, single-maintainer project, so pin to a specific known-good revision and make that revision easy to find/bump later, e.g. as a `Dockerfile` build arg or a comment noting the pinned SHA). Run `npm ci --omit=dev` (or `npm install --production`) to install its three dependencies at build time — don't fetch them at container start. - Two reasonable build strategies, either is fine — pick whichever gives the smaller/cleaner image: - Start `FROM` a Node base image, then install Python/pip and `mcpo` on top. - Start `FROM` a Python base image, then install Node.js on top. - A multi-stage build that vendors `webfetch-mcp` in one stage and copies it (`COPY --from=`) into the final runtime stage is also fine if it keeps the Dockerfile cleaner — but the shipped artifact is still one image containing both. 2. **Wire the two together via a runtime entrypoint script, not a fixed `CMD` array.** This is the part that needs the most care, so being explicit: - `mcpo`'s `--api-key` is a CLI flag, not something the process reads from its own environment — but the actual key value needs to be supplied when the *container* is started (`docker run -e ...` / an orchestrator's `environment:` block), not baked into the image. A Dockerfile's `CMD ["mcpo", "--port", "8000", "--api-key", "???", "--", "node", "server.mjs"]` can't do this, because there's no way to splice an environment variable into a fixed exec-form array. - Solution: make the image's entrypoint a small shell script (`ENTRYPOINT ["/entrypoint.sh"]`) that reads environment variables at container start and constructs the `mcpo` command line, e.g.: ```sh #!/bin/sh set -eu PORT="${MCPO_PORT:-8000}" if [ -n "${MCPO_API_KEY:-}" ]; then exec mcpo --port "$PORT" --api-key "$MCPO_API_KEY" -- node /app/server.mjs else exec mcpo --port "$PORT" -- node /app/server.mjs fi ``` (adjust the vendored path as needed). No API key set → `mcpo` runs with no auth, matching its own default behavior — don't invent a different default. - `SEARXNG_BASE` (and `DEBUG`/`DETAILED_LOG` if useful) should reach `webfetch-mcp` unmodified. Since `mcpo` spawns `node server.mjs` as a child process, environment variables set on the container process are expected to be inherited by that child automatically (standard subprocess-spawning behavior) — **verify this holds for `mcpo`'s specific process-spawning code** (i.e. that it doesn't strip/reset the environment when launching its child) rather than assuming it. If it doesn't inherit automatically, the entrypoint script will need to pass it through explicitly. 3. **Environment variable contract the image should honor** (document these in the repo's README): | Variable | Effect | |---|---| | `MCPO_API_KEY` | If set, passed as `mcpo --api-key`. If unset, `mcpo` runs unauthenticated. | | `MCPO_PORT` | Port `mcpo` listens on inside the container. Default `8000`. | | `SEARXNG_BASE` | Passed through to `webfetch-mcp`; its own default (`http://localhost:8080`) applies if unset. | | `DEBUG` | Passed through to `webfetch-mcp` (optional). | (Naming above is a suggestion, not a hard requirement — pick clear names and document whatever you land on.) 4. **Expose the port** `mcpo` listens on (`EXPOSE 8000` or matching whatever `MCPO_PORT` defaults to). 5. **CI**: add a Gitea Actions workflow that builds the image on push to `main` and pushes it to this Gitea instance's container registry (`code.aneur.in/<org>/<this-repo-name>`), authenticating via a repo secret holding a personal access token with package-write scope (the auto-injected default token does not have package-registry permissions on this Gitea instance — a manually-created repo secret is required). Tag as `latest`. ## Non-goals - **No URL filtering / SSRF protection.** `webfetch-mcp` fetches whatever URL it's given, including private/internal addresses. Building a filter for this is explicitly out of scope for this issue — document it as a known characteristic in the README, but don't try to fix it here. - No changes to `webfetch-mcp`'s own logic — vendor it as-is at a pinned revision. - No TLS termination — this image serves plain HTTP; anything in front of it (reverse proxy, firewall) is somebody else's concern. ## Acceptance criteria - `docker build` produces an image. - `docker run -p 8000:8000 -e MCPO_API_KEY=testkey -e SEARXNG_BASE=http://example.invalid:8080 <image>` starts cleanly and logs show `mcpo` up and `webfetch-mcp` spawned as its child. - `curl http://localhost:8000/docs` (or `/openapi.json`) returns `mcpo`'s generated API docs/spec, showing `web_search` and `web_fetch` as available operations. - A request to one of those endpoints without the correct API-key credential is rejected; with the correct `testkey` it's accepted and reaches `webfetch-mcp` (a `web_fetch` call against a real, reachable URL should return extracted content — a `web_search` call needs an actual SearXNG instance to fully verify, so a mocked/expected-error response is acceptable for that half if no SearXNG instance is available during testing). - README documents: how to build, the environment variable contract above, and the accepted no-URL-filtering caveat.
Sign in to join this conversation.