Skip to content

MCP server: HTTP transport with per-request auth forwarding, packaged as opensource-mcp#425

Merged
runleveldev merged 12 commits into
mainfrom
mcp-http-auth-forwarding
Jul 24, 2026
Merged

MCP server: HTTP transport with per-request auth forwarding, packaged as opensource-mcp#425
runleveldev merged 12 commits into
mainfrom
mcp-http-auth-forwarding

Conversation

@runleveldev

Copy link
Copy Markdown
Collaborator

Summary

Adds an HTTP transport to the manager-control-program MCP server with per-request Authorization forwarding, and ships it to production as its own opensource-mcp Debian package that the Manager reverse-proxies at /mcp.

MCP clients connect at the Manager's public origin over TLS (e.g. https://manager.example.com/mcp); each caller's Bearer API key flows through untouched to the create-a-container REST API, so every client acts under its own key with no shared AUTH_TOKEN at startup.

What's in here

Server (manager-control-program)

  • New SERVER_TRANSPORT=http|streamable-http|sse (default stays stdio). In HTTP mode an httpx.Auth hook reads each incoming MCP request's Authorization header from fastmcp's request context and forwards it on the outgoing API call. AUTH_TYPE=bearer + AUTH_TOKEN still works as a static fallback. stdio behavior is unchanged.
  • Made the project actually installable (hatchling build-system + missing __init__.py) so uvx/uv run work.

Packaging & deployment

  • manager-control-program now builds a standalone opensource-mcp package (deb/rpm/apk) with all Python deps vendored (prebuilt CPython 3.13 wheels) — the target host needs only python3 (no uv/pip/venv/network). Pinned python3 >= 3.13, < 3.14 to match the cp313 wheels' ABI.
  • opensource-server now --depends opensource-mcp instead of bundling it. Added to root COMPONENTS, the release workflow, builder Dockerfile, and manager image.
  • Runs as opensource-mcp.service on 127.0.0.1:8100.
  • The Manager mounts a dependency-free streaming reverse proxy at /mcp before the body parsers/session/rate-limit/CSRF, so MCP request bodies stream through untouched and SSE responses stay open (MCP_SERVER_URL).

Config in /etc/default (not baked into units)

  • opensource-mcp ships /etc/default/opensource-mcp as a package config file carrying API_BASE_URL and SERVER_TRANSPORT/HOST/PORT; the unit keeps only PYTHONPATH.
  • MCP_SERVER_URL moved out of container-creator.service into /etc/default/container-creator (written by container-creator-init alongside the DB settings).

Docs

  • Restructured the MCP Server docs page (Option A: local stdio via uvx; Option B: shared HTTP server with your own API key) and added a hosting section; moved to the "four deployable components" model.

Testing

  • Jest suite covers the reverse proxy: header/body/query passthrough, SSE content type, 502 when the MCP server is down, and unmounted when MCP_SERVER_URL is unset.
  • Built the real opensource-mcp and opensource-server debs with fpm and verified contents: vendored dependency tree, opensource-mcp.service, postinst/prerm, correct python3 dependency range, /etc/default/opensource-mcp registered as a conffile, and opensource-server depending on opensource-mcp without any vendored Python code.
  • Ran the staged tree on a bare CPython 3.13 behind the real proxy middleware with two MCP clients — each caller's token reached the stub API intact through both hops; a headerless client produced no Authorization header; stdio still fails fast without AUTH_TOKEN.

Copilot AI review requested due to automatic review settings July 24, 2026 13:58
Comment thread create-a-container/middlewares/mcp-proxy.js Dismissed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a deployable MCP server surface for create-a-container that can be reached over HTTPS via the Manager (/mcp), with per-request Authorization forwarding end-to-end, and packages the MCP server as a standalone opensource-mcp OS package with vendored Python dependencies.

Changes:

  • Add a new opensource-mcp packaged MCP server (systemd unit + /etc/default config) and integrate it into the build/release pipeline.
  • Add an Express streaming reverse proxy middleware mounted at /mcp, plus Jest coverage for header/body/query passthrough, SSE behavior, and upstream failure handling.
  • Update docs to describe local stdio vs built-in HTTP usage, plus the new “four deployable components” model.

Reviewed changes

Copilot reviewed 24 out of 26 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
mie-opensource-landing/docs/users/mcp-server.md Documents stdio vs built-in HTTP MCP usage and hosting details.
mie-opensource-landing/docs/developers/release-pipeline.md Updates release docs to include the new opensource-mcp component.
manager-control-program/uv.lock Updates lock metadata to treat the project as editable/installable.
manager-control-program/README.md Reframes usage, packaging details, and transport/auth configuration.
manager-control-program/pyproject.toml Makes the project installable via an explicit build system (hatchling).
manager-control-program/manager_control_program/server.py Adds HTTP transports and per-request Authorization forwarding.
manager-control-program/manager_control_program/init.py Marks the package as importable/installable.
manager-control-program/Makefile Adds packaging/staging logic to build opensource-mcp with vendored deps.
manager-control-program/contrib/systemd/opensource-mcp.service New systemd unit to run the MCP server on loopback.
manager-control-program/contrib/preremove.sh Stops/disables the unit on package removal.
manager-control-program/contrib/postinstall.sh Enables (and restarts when possible) the MCP service post-install.
manager-control-program/contrib/default/opensource-mcp Provides /etc/default runtime config for the MCP service.
manager-control-program/.gitignore Ignores packaging outputs/staging artifacts.
manager-control-program/.fpm Defines the opensource-mcp package metadata/dependencies/conffiles.
Makefile Adds manager-control-program to the root components build list.
images/manager/Dockerfile Installs opensource-mcp into the manager image.
images/manager/container-creator-init.service Writes MCP_SERVER_URL into /etc/default/container-creator on first boot.
images/builder/Dockerfile Updates builder image docs/comments for building 4 packages.
create-a-container/README.md Notes opensource-server now depends on opensource-mcp and proxies /mcp.
create-a-container/middlewares/mcp-proxy.js Implements a streaming reverse proxy for /mcp.
create-a-container/middlewares/tests/mcp-proxy.test.js Adds Jest coverage for proxy passthrough and failure modes.
create-a-container/example.env Documents MCP_SERVER_URL env var for enabling the proxy.
create-a-container/contrib/systemd/container-creator.service Documents that /etc/default/container-creator now includes MCP config.
create-a-container/app.js Mounts /mcp proxy early (before parsers/session/rate-limit/CSRF).
create-a-container/.fpm Adds runtime dependency on opensource-mcp.
.github/workflows/release.yml Includes opensource-mcp in release artifacts/flat APT metadata.
Comments suppressed due to low confidence (1)

manager-control-program/manager_control_program/server.py:80

  • Typo in comment: awslabs.openapi_mpc_server should be awslabs.openapi_mcp_server.
    # awslabs.openapi_mpc_server.server:main function with the small exception

Comment on lines +36 to +42
function filterHeaders(headers) {
const out = {};
for (const [name, value] of Object.entries(headers)) {
if (!HOP_BY_HOP.has(name.toLowerCase())) out[name] = value;
}
return out;
}
Comment on lines +54 to +55
# No test suite yet; kept as a no-op so the repo-wide `make test` loop stays
# uniform if this component is ever added to the root COMPONENTS list.
if transport == "stdio":
# We default to Bearer auth which requires the user to have set the
# AUTH_TOKEN environment variable. I'm unsure if any other auth
# types work, but we leave that door open incase it's needed.
…orwarding

Adds SERVER_TRANSPORT=http|streamable-http|sse (default remains stdio).
In HTTP mode the server no longer requires AUTH_TOKEN at startup:
a client-level httpx auth hook reads each incoming MCP request's
Authorization header from fastmcp's request context and forwards it on
the outgoing API call, so every caller authenticates to the API as
themselves. fastmcp's built-in header passthrough deliberately strips
authorization, hence the explicit include.

Setting AUTH_TYPE=bearer with AUTH_TOKEN alongside HTTP mode still
works as a static fallback for requests without a header. stdio
behavior is unchanged (bearer + AUTH_TOKEN required).

Verified end-to-end against a stub API: two HTTP MCP clients with
different bearer tokens each hit the API under their own token; a
headerless client produced no Authorization header; stdio mode still
fails fast without AUTH_TOKEN; invalid transports error clearly.
Without a [build-system], uv treats the project as virtual and never
installs it into the venv, so the manager-control-program console
script doesn't exist: the documented `uv run manager-control-program`
and `uvx --from git+...` invocations fail with 'Failed to spawn'.

Add a hatchling build-system and the missing package __init__.py.
uv.lock: source flips from virtual to editable.
… HTTP mode

The MCP Server page in mie-opensource-landing is the real setup guide:
restructure it around the two ways to use the server (Option A: local
stdio via uvx, Option B: connecting to a shared HTTP server with your
own API key in the Authorization header) and add a hosting section for
the new HTTP transport with its env vars and security caveats.

Slim the component README to the house style used by create-a-container:
component overview, defer-to-docs table, local development, environment
variable reference, and how auth works per transport - dropping the MCP
client config JSON it previously duplicated from the docs site.
…mcp to it

Package manager-control-program inside the opensource-server package
with vendored Python dependencies so it runs without uv:

- manager-control-program gains the standard component Makefile; its
  install target exports the uv lockfile and installs prebuilt wheels
  (Debian 13 / amd64 / CPython 3.13) plus the app into
  /opt/opensource-server/manager-control-program. The target host needs
  only /usr/bin/python3.
- New manager-control-program.service systemd unit runs it via
  python3 -m with PYTHONPATH at the vendored dir: HTTP transport on
  127.0.0.1:8100, per-request Authorization forwarding, no AUTH_TOKEN.
  Optional overrides via /etc/default/manager-control-program.
- create-a-container's install stages it, postinstall/preremove
  enable/disable it alongside the existing units.
- The Manager reverse-proxies /mcp to it (MCP_SERVER_URL, set in
  container-creator.service): a dependency-free streaming proxy mounted
  before the body parsers/session/rate-limit/CSRF so MCP request bodies
  stream through untouched and SSE responses stay open. MCP clients
  connect at the Manager's public origin with TLS, e.g.
  https://manager.example.com/mcp.

Verified: jest suite covers the proxy (headers/body/query passthrough,
SSE content type, 502 when down, unmounted when unconfigured); built
the real deb with fpm and checked contents (vendored tree, all three
units, postinst); ran the staged tree on a bare CPython 3.13 behind the
real proxy middleware with two MCP clients — each caller's token
reached the stub API intact through both hops.
manager-control-program.service execs /usr/bin/python3, but the package
never declared it, so on a fresh install without python3 the service
failed to start. Pin the range to the vendored wheels' ABI: compiled
wheels are cp313-only, so python3 >= 3.13 and < 3.14 (fpm translates
the operators per package format; verified deb and rpm output). The
Makefile comment cross-references the pin so a future PYTHON_VERSION
bump updates both together.
…nsource-mcp package

Split the MCP server out of the opensource-server package into a
standalone opensource-mcp package instead of bundling it inside
create-a-container's build.

- manager-control-program now builds its own deb/rpm/apk: the Makefile
  gains a package target (stage into .pkg/buildroot, then fpm) and a
  .fpm declaring the opensource-mcp package, with the python3 >= 3.13,
  < 3.14 dependency pin moved here from create-a-container/.fpm (it
  tracks the vendored cp313 wheels' ABI). Own postinstall/preremove
  enable/disable the unit.
- The systemd unit is renamed manager-control-program.service ->
  opensource-mcp.service, with its EnvironmentFile at
  /etc/default/opensource-mcp.
- create-a-container no longer calls this component's install target or
  ships its units; it just --depends opensource-mcp. Its
  postinstall/preremove drop the MCP unit, and MCP_SERVER_URL still
  points the reverse proxy at the loopback service.
- Root Makefile adds manager-control-program to COMPONENTS (ordered
  before create-a-container so the dependency builds first); the
  release workflow, builder Dockerfile, manager image, and docs move
  to a four-package model, and .gitignore covers the new build
  artifacts (.pkg/, *.deb/rpm/apk).

Verified: `make deb` in manager-control-program produces
opensource-mcp with the correct python3 dependency range, the
opensource-mcp.service unit, and the vendored dependency tree;
`make deb` in create-a-container produces opensource-server depending
on opensource-mcp and no longer containing the vendored Python code.
…o /etc/default

The units this branch added hardcoded configuration via Environment=
lines, which admins can't change without editing the shipped unit.
Move that config into /etc/default so it can be edited (and survives
package upgrades), keeping only fixed runtime constants in the units.

- opensource-mcp: ship /etc/default/opensource-mcp (a package config
  file via fpm --config-files) with API_BASE_URL and
  SERVER_TRANSPORT/HOST/PORT; the unit now keeps only PYTHONPATH and
  reads the (now required) EnvironmentFile. Makefile installs it.
- container-creator: MCP_SERVER_URL moves from the unit into
  /etc/default/container-creator, appended by container-creator-init
  alongside the DB settings it already generates.
- README/docs: describe the settings as living in the packaged config
  file rather than as inline unit overrides.

Verified: opensource-mcp.deb registers /etc/default/opensource-mcp as a
conffile carrying the four values and its unit body no longer contains
them; container-creator's unit no longer sets MCP_SERVER_URL and the
init service writes it into /etc/default/container-creator.
…ast the app-level CSRF guard

The manager's own agent checks in over localhost during bootstrap before
any site row or API key exists, so its POST /api/v1/agents carries
neither a Bearer token nor a CSRF token. The agents router already
exempts this via a route-level localhost bypass (checkinAuth), but the
app-level csrfGuard in app.js runs first and only exempted
Bearer-without-cookie requests — so the credential-less localhost
check-in was rejected with 403 csrf_invalid before ever reaching the
route.

Mirror the route-level bypass in csrfGuard: skip CSRF for localhost
requests (isLocalhostRequest also rejects proxied/remote clients via
X-Real-IP / X-Forwarded-For, so this doesn't widen the trust surface).
isLocalhostRequest is required lazily to avoid a load-order cycle with
middlewares/index.

Adds a regression test covering the localhost bootstrap check-in (not
403) and a proxied-remote credential-less check-in (still rejected).
…ount it into the dev compose stack

The MCP server runs from /opt/opensource-server/manager-control-program
(PYTHONPATH) with only the system python3 — the vendored Python deps
must already be present there. In the dev compose stack the repo is
mounted read-only into Proxmox and those deps aren't checked in, so
nothing populated that path and opensource-mcp.service couldn't start
from a fresh checkout.

- make build now assembles the ready-to-run tree (app package + vendored
  wheels for Debian 13 / amd64 / CPython 3.13) into build/ instead of
  being a no-op. make install just copies build/ into DESTBIN, so the
  dev bind mount and the packaged install serve byte-for-byte the same
  layout. clean removes build/. (build/ is already gitignored.)
- compose gains an `mcp` service that runs `make -C manager-control-program
  build` (repo mounted read-write, like the node/client services; the uv
  image lacks make, so it's apt-installed first). The proxmox service
  overlays ./manager-control-program/build onto
  /opt/opensource-server/manager-control-program read-only and depends on
  `mcp` completing, so the server stands up from scratch.

Verified: `make build` then `make install DESTDIR=...` produce the same
tree; it imports and runs under CPython 3.13 with only PYTHONPATH set;
`make deb` still packages the same contents; and the `mcp` compose
service populates build/ from a clean checkout.
…h in dev and the package

Follow-up to assembling the runtime tree in build/: make that directory
the actual runtime path in every context so it matches the git repo
layout (manager-control-program/build).

- opensource-mcp.service points WorkingDirectory and PYTHONPATH at
  /opt/opensource-server/manager-control-program/build.
- make install copies build/ into DESTBIN/build (not flattened into
  DESTBIN), so the package ships the tree at
  /opt/opensource-server/manager-control-program/build — the same path
  the read-only repo mount exposes in the dev container.
- Drop the proxmox overlay bind mount: the read-only repo mount already
  exposes manager-control-program/build at that path once the `mcp`
  service has built it.
- Use the non-slim astral/uv image for the `mcp` service, which already
  ships make, dropping the apt-get step.

Verified: the deb installs the tree at .../manager-control-program/build
and the unit's PYTHONPATH matches; it imports and runs under CPython
3.13 from build/; the `mcp` compose service builds it from a clean
checkout with no apt step.
…on failure

The agent only logged a handful of one-line messages via console.*, and
crucially discarded the output of `nginx -t` (run with stdio 'pipe'),
so when nginx rejected a rendered config the journal showed
"config test failed" with no indication of *why*.

- New agent/src/log.ts: a minimal leveled logger (error/warn/info/debug)
  gated by LOG_LEVEL (default info; unknown values warn and fall back).
  error/warn go to stderr, info/debug to stdout, each line level-tagged
  so `journalctl -u opensource-agent` output is greppable. commandOutput()
  extracts the captured stdout/stderr from a failed execFileSync error.
- apply.ts: run() now captures command output; on a failed config test
  (nginx -t / dnsmasq --test) the actual validator output is logged at
  error level. Added render/diff/write/test/reload trace logging (debug)
  plus info-level "applying"/"test passed"/"reloaded" milestones, and the
  reload-after-rollback failure is now logged instead of swallowed.
- index.ts: log startup, per-pass check-in outcome (304 vs new config),
  and MAX_PASSES exhaustion; the top-level handler now logs the full
  stack, not just err.message.
- api.ts: log the check-in request and distinguish a manager timeout from
  other network errors and from non-2xx responses.
- state.ts: route the corrupt-state warning through the logger.
- opensource-agent.service: document the optional LOG_LEVEL env var.

Verified: tsc builds clean; default level hides debug and shows
info/warn/error; a simulated nginx -t failure surfaces the emerg line;
LOG_LEVEL=debug enables debug; unknown levels fall back to info.
@runleveldev
runleveldev force-pushed the mcp-http-auth-forwarding branch from 318ffb6 to 960e7ac Compare July 24, 2026 15:23
…ver path

MCP tool calls were returning the Manager's SPA HTML instead of JSON.
fastmcp builds each generated tool's request URL by joining the raw
OpenAPI path (e.g. "/sites") onto the httpx client's base_url and
ignores the spec's `servers` entry. Our spec declares
`servers: [{url: /api/v1}]`, so with base_url=API_BASE_URL the tools
requested http://host:3000/sites — which the Manager serves as the
single-page app's HTML catch-all — instead of /api/v1/sites.

Read servers[0].url from the fetched spec and append its path to the
client's base_url after create_mcp_server, so requests resolve to
/api/v1/<path>. A relative server url contributes its path directly; an
absolute one contributes only its path so API_BASE_URL's (loopback)
host is preserved; an empty/root server leaves base_url unchanged.

Verified against a stub API whose spec declares servers:/api/v1: the
client base_url becomes http://host/api/v1/ and a tool for "/sites"
resolves to /api/v1/sites.
@runleveldev
runleveldev merged commit ee03a1b into main Jul 24, 2026
8 checks passed
@runleveldev
runleveldev deleted the mcp-http-auth-forwarding branch July 24, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants