feat(deploy): streamable-HTTP transport, Dockerfile and Forgejo CI #3

Merged
jlxq0 merged 12 commits from http-transport into master 2026-08-26 05:28:46 +00:00
Owner

Closes #1.

listmonk-mcp could not be deployed: it spoke stdio only, there was no image, and no Forgejo workflow had ever run. This is all three pieces.

Retargeted to master. #2 merged at 3b80c11, so master now carries the 41 tools and this diff is only the deployment work.

Fork or upstream

Fork patch, all of it. The two-listener contract, the UID-65532 runtime, the forge.oddie.app/jlxq0 registry and the :buildcache guard are this fleet's deployment shape and mean nothing upstream.

The one genuinely upstreamable piece is the streamable-HTTP transport itself — mcp 1.11.0 already supports it, no dependency bump — but as written it is entangled with the metrics split. Sending it up would mean extracting transport.py minus the metrics listener, which is a separate change and worth proposing later rather than blocking this.

1. Streamable-HTTP transport

server.run() with no argument is stdio, which cannot sit behind an HTTPRoute. Adds --transport streamable-http / LISTMONK_MCP_TRANSPORT. stdio keeps working and is still the default.

listener default carries env override
public 0.0.0.0:3000 /mcp, /health LISTMONK_MCP_BIND_ADDR
internal 127.0.0.1:9090 /metrics LISTMONK_MCP_METRICS_BIND_ADDR

The metrics listener resolves explicit env → {POD_IP}:9090127.0.0.1:9090, and never 0.0.0.0. One listener would be less code and would put /metrics behind the same public hostname as /mcp, which is the thing the split prevents. That reason does not depend on the language.

The Listmonk client is managed at the ASGI lifespan, not FastMCP's. FastMCP threads its lifespan into the low-level Server, which the streamable-HTTP session manager runs once per MCP session. Passing it through would connect and disconnect per client session, and two concurrent sessions would race on the module globals — the first to finish closing the client the second was still using. Over stdio the per-session lifespan is correct, because there is one session per process, so that path is unchanged.

Metrics are hand-rolled Prometheus text rather than prometheus-client: the exposition is a few dozen lines, and a dependency added to a locked, deployed image costs more than maintaining them. Instrumentation lives in call_tool, the one choke point every dispatch passes through, so it cannot be forgotten when a tool is added.

2. Dockerfile

Two stages, both python:3.13-slim-bookworm pinned by digest. Distroless carries no Python interpreter, so the runtime stage differs from caldav-mcp; nothing else does. uv comes from its own digest-pinned image, dependencies are a layer separate from source, runtime is non-root UID 65532 to match the distroless nonroot the Rust servers use, and BUILD_VERSION / BUILD_REVISION / BUILD_CREATED feed the OCI labels. Port 9090 is deliberately not EXPOSEd.

3. Forgejo Actions workflow

ruff, mypy (strict = true) and pytest, none of which has ever run here, then buildctl against the runner's buildkitd. There was no tests/ directory at all; there are now 54 tests.

Exactly one writer to :buildcache

Guarded on refs/heads/master by name — this repo's default branch is master, not main, and a copied main guard would never fire and never say so. The tag build imports and does not export. github.event_name != 'pull_request' is not equivalent: it is also true on a tag push, which is how typst-mcp had one export site and two writers.

Verified by execution, because the three obvious checks do not work here:

  • A green docker job proves nothing. caldav-mcp 16484 and 16485 overlapped, both exported to one ref, both passed.
  • An unchanged :buildcache digest proves nothing. The manifest is content-addressed, so a full hit re-exports identical bytes to an identical digest.
  • Grepping the job log proves nothing. Retention is sporadic and 0 is also the answer that means correct.

scripts/check_buildcache_guard.py pulls the docker build script out of the YAML with PyYAML and runs its real branch logic — only buildctl, node and sudo are stubbed — counting the arguments buildctl actually received:

  refs/heads/master           --export-cache x1  --import-cache x1
  refs/tags/v0.2.0            --export-cache x0  --import-cache x1
  refs/pull/1/head            --export-cache x0  --import-cache x0
  refs/heads/feat/some-branch --export-cache x0  --import-cache x0

OK: exactly one writer to :buildcache, on refs/heads/master

It then breaks the guard three ways and requires each break to be caught, so its failure path executes on every CI run:

self-test (breaking the guard on purpose):
  self-test rejected: guarded on event_name instead of the branch ref
      refs/heads/feat/some-branch: expected 0 --export-cache, got 1.
  self-test rejected: second writer on the tag build (the m365-mcp#4 shape)
      refs/tags/v0.2.0: expected 0 --export-cache, got 1.
  self-test rejected: guarded on refs/heads/main, which this repo does not have
      refs/heads/master: expected exactly 1 --export-cache, got 0.

Breaking the real ci.yml (refs/heads/masterevent_name != 'pull_request') takes the harness to exit 1, and restoring it returns exit 0.

Residual, known: the guard makes the ref classes disjoint. Two pushes to master in quick succession would still overlap. A concurrency: group is not applied because Forgejo's support for it is unverified here and an unsupported key is ignored silently — which would look fixed and not be. The other five servers carry the same residual; m365-mcp#7 tracks it.

Verification

Everything below was run, not asserted.

  • ruff, mypy --strict, pytest (54 passed), and the guard, all green on Python 3.13 — the version CI and the image both install.
  • Server actually serving. Started in streamable-HTTP mode against a local stub Listmonk: GET /health200 {"status":"healthy","version":"0.2.0","revision":"..."}; GET /metrics on the public listener → 404; a real MCP session over /mcp doing initialize, tools/list (70 tools) and tools/call, returning stub data. /metrics on the internal listener showed the resulting counters.
  • The image running. docker build, then the container serving the same three checks, plus docker stop exiting 0 — which is what proves the two listeners shut down together.
  • stdio unchanged, verified with a stdio MCP client: initialize, 70 tools, a working tools/call.
  • Every load-bearing assertion mutation-tested. Twelve deliberate breaks — metrics defaulting to 0.0.0.0, POD_IP ignored, non-cumulative buckets, label escaping removed, /metrics leaked onto the public app, error calls uncounted, tools not copied, per-session lifespan reintroduced, /health leaking the credential, listener failures swallowed, the surviving listener never asked to exit, silent cancellation — each turned the suite red, and each was reverted.

Three defects found by running rather than compiling

Two fail at container start, not at build, so a green build proves neither:

  • uv sync installs the project editable by default, leaving a .pth pointing at the builder's /build/srcModuleNotFoundError. Fixed with --no-editable.
  • uv bakes an absolute interpreter path into every console-script shebang, so a venv built at /build/.venv and copied to /app/.venv exec's a path that does not exist. Fixed with UV_PROJECT_ENVIRONMENT.

One found by reading the live /metrics: an unregistered tool name arriving over the wire became a metric label. Any client could grow the registry without bound, one label set per request, for the life of the process. Unregistered names now collapse to __unknown__; proven against the running server with 20 random names producing exactly one label.

Cross-engine review

codex exec --sandbox read-only, two specific questions. It confirmed the lifespan nesting order and confirmed the guard executes the workflow's real logic. It found three real shutdown defects, all fixed with tests that fail without the fix:

  • lifespan wrapped the yield, so every later failure — including one raised during session-manager shutdown — was logged as "Failed to start server".
  • serve() swallowed a failure from the listener that finished during the shutdown window; gather(return_exceptions=True) ate it.
  • A listener that never stopped was cancelled silently, abandoning graceful ASGI shutdown without saying so.

Out of scope, deliberately

No Listmonk API user or role was created and the live Listmonk was not touched — that credential's scope is Julian's decision. Local verification used a stub. No release tag pushed.

AGENTS.md and CLAUDE.md added, per the repo convention: CLAUDE.md is the single line @AGENTS.md, and AGENTS.md holds the fork relationship, the master-not-main consequence, the deployment contract, the one-writer rule with its reasoning and residual, and the pitfalls above.

AGENTS.md also records that .forgejo/workflows shadows .github/workflows: upstream's PyPI-publish and Pages-docs workflows do not run once .forgejo/ is in the tree, and must not be deleted — deleting them buys nothing and costs fork divergence plus a merge conflict against upstream. Established by observation: caldav-mcp carries both, its .github/workflows/ci.yml declares jobs quality and container, and across all 35 task records there the only jobs that have ever run are cargo and docker.

(An earlier revision of this branch claimed forge.sh new hardcodes origin/main. It no longer does — it reads refs/remotes/origin/HEAD with an origin/main then origin/master fallback. Corrected in d7c6c73.)


Added after review, 2026-08-26

The buildcache guard passed against a workflow that never ran

scripts/check_buildcache_guard.py greps for the literal refs/heads/main. The
push trigger's filter is branches: [master], which is not that string, so
changing it to branches: [main] — the same copy-from-another-repo mistake one
line higher in the same file — left the check exit 0, printing "OK: exactly
one writer to :buildcache, on refs/heads/master".

It is the worse of the two faults. The workflow never fires on master, so no
image is built, no cache is written, and the guard itself stops running: the
check that would have caught it is the thing the break disables.

Found by breaking the check two ways rather than one. Break one, the export
guard changed to refs/heads/main, was caught with four failures. Stopping
there would have left the guard looking sound.

Five cross-engine passes, five findings, all real

Each fix invited the next, which is the argument for the last one:

pass edit that passed exit 0 effect
1 branches: [main] workflow never fires on master
2 paths-ignore: ["**"] every master push filtered out
3 strategy: matrix on docker N concurrent exporters from one push
4 needs: nonexistent docker job never scheduled
5 runs-on: <label-no-runner-has> docker job never scheduled

Plus if: false on the docker job, on the build step, and on the guard step
itself.

Naming keys one at a time does not terminate, so the final check is fail-closed
and by value: on.push, both job headers (everything outside steps:) and
the two steps that must run unconditionally are compared to expected mappings.
Anything new fails until a person has read it. Keys alone were not enough —
runs-on keeps its key and schedules nothing.

Fifteen breaks now execute on every CI run, up from three.

Where it stops. A sixth pass proposed exit 1 in the Install buildctl
step. That does stop the build and turns the run red, which the CI status
already reports and which blocks the merge. Every edit whose effect is a
failing job is out of scope by construction; this file defends against a
workflow reporting success while having built nothing or exported twice. The
one edit that would convert red into green, continue-on-error: true, is
refused by the job-header pin.

The two API fixes are now observable

dc07b44 and 4fd84c2 were behaviour changes to the request body with nothing
in the suite that could see them: all 54 tests imported transport, metrics
or server, and none imported client. Neither fix changes a return value, so
old and new code both succeed against a permissive server — the difference is
only in the bytes on the wire.

12 tests against a stub HTTP server that records requests. Every named mutation
was run:

mutation red
data = {"subscribers": subscribers} (reverts dc07b44) 3 tests
lists passed through as objects, not ids 1
drop the "subscribers" key from the merged payload 1
json_data={"value": value} (reverts 4fd84c2) 7
the same wrapper on the bulk PUT /api/settings 1

Cross-engine review then found two assertions pinning less than they read, both
real, both fixed and both re-verified by mutation:

  • assert recorded.json == value conflates True with 1 and False with
    0. A client serialising booleans as integers stayed green on exactly the
    two cases where listmonk cares. Now asserts the raw bytes.
  • stub.last("PUT", "/api/settings") matched by substring, so
    /api/settings/anything satisfied it — a correct body sent to the wrong
    endpoint passed the per-key-versus-bulk distinction the test draws. Now
    matches the path exactly.

Asserting raw bytes surfaced a limitation, pinned rather than fixed: httpx
reads json=None as "no JSON body" rather than the JSON value null, so
update_setting(key, None) sends an empty body. Whether any setting needs a
null is a question for someone with the settings document in front of them.

AGENTS.md

"Until pull request #2, master was byte-identical to upstream" was past
tense about something still true when written, and the cost is named in the
paragraph it sits in: someone picks master as a base and ships upstream's
server without the 41 tools. Now present tense and carrying its verification
date. Break B is written down there too, so the finding survives even if this
patch is later rewritten.

Toolchain

There is no setup-python step and no toolchain pin in ci.yml — this repo is
the only one in the fleet without one. Checked rather than assumed: the pin is
.python-version (3.13), uv honours it, and the Dockerfile pins
python:3.13-slim-bookworm by digest, so CI and the image agree. pyproject
declares requires-python = ">=3.11" and nothing exercises 3.11 at runtime;
mypy is configured at 3.11, so the floor is type-checked but not run.

66 tests, ruff, mypy --strict and the guard all green locally on 3.13.

Closes #1. `listmonk-mcp` could not be deployed: it spoke stdio only, there was no image, and no Forgejo workflow had ever run. This is all three pieces. **Retargeted to `master`.** #2 merged at `3b80c11`, so `master` now carries the 41 tools and this diff is only the deployment work. ## Fork or upstream **Fork patch, all of it.** The two-listener contract, the UID-65532 runtime, the `forge.oddie.app/jlxq0` registry and the `:buildcache` guard are this fleet's deployment shape and mean nothing upstream. The one genuinely upstreamable piece is the streamable-HTTP transport itself — `mcp` 1.11.0 already supports it, no dependency bump — but as written it is entangled with the metrics split. Sending it up would mean extracting `transport.py` minus the metrics listener, which is a separate change and worth proposing later rather than blocking this. ## 1. Streamable-HTTP transport `server.run()` with no argument is stdio, which cannot sit behind an HTTPRoute. Adds `--transport streamable-http` / `LISTMONK_MCP_TRANSPORT`. **stdio keeps working and is still the default.** | listener | default | carries | env override | |---|---|---|---| | public | `0.0.0.0:3000` | `/mcp`, `/health` | `LISTMONK_MCP_BIND_ADDR` | | internal | `127.0.0.1:9090` | `/metrics` | `LISTMONK_MCP_METRICS_BIND_ADDR` | The metrics listener resolves explicit env → `{POD_IP}:9090` → `127.0.0.1:9090`, and **never** `0.0.0.0`. One listener would be less code and would put `/metrics` behind the same public hostname as `/mcp`, which is the thing the split prevents. That reason does not depend on the language. **The Listmonk client is managed at the ASGI lifespan, not FastMCP's.** FastMCP threads its `lifespan` into the low-level `Server`, which the streamable-HTTP session manager runs once per MCP *session*. Passing it through would connect and disconnect per client session, and two concurrent sessions would race on the module globals — the first to finish closing the client the second was still using. Over stdio the per-session lifespan is correct, because there is one session per process, so that path is unchanged. Metrics are hand-rolled Prometheus text rather than `prometheus-client`: the exposition is a few dozen lines, and a dependency added to a locked, deployed image costs more than maintaining them. Instrumentation lives in `call_tool`, the one choke point every dispatch passes through, so it cannot be forgotten when a tool is added. ## 2. Dockerfile Two stages, both `python:3.13-slim-bookworm` pinned by digest. Distroless carries no Python interpreter, so the runtime stage differs from `caldav-mcp`; nothing else does. uv comes from its own digest-pinned image, dependencies are a layer separate from source, runtime is non-root **UID 65532** to match the distroless `nonroot` the Rust servers use, and `BUILD_VERSION` / `BUILD_REVISION` / `BUILD_CREATED` feed the OCI labels. Port 9090 is deliberately not `EXPOSE`d. ## 3. Forgejo Actions workflow `ruff`, `mypy` (`strict = true`) and `pytest`, none of which has ever run here, then `buildctl` against the runner's buildkitd. There was **no `tests/` directory at all**; there are now 54 tests. ### Exactly one writer to `:buildcache` Guarded on **`refs/heads/master`** by name — this repo's default branch is `master`, not `main`, and a copied `main` guard would never fire and never say so. The tag build imports and does not export. `github.event_name != 'pull_request'` is not equivalent: it is also true on a tag push, which is how `typst-mcp` had one export site and two writers. Verified by execution, because the three obvious checks do not work here: - A green `docker` job proves nothing. caldav-mcp `16484` and `16485` overlapped, both exported to one ref, both passed. - An unchanged `:buildcache` digest proves nothing. The manifest is content-addressed, so a full hit re-exports identical bytes to an identical digest. - Grepping the job log proves nothing. Retention is sporadic and `0` is also the answer that means correct. `scripts/check_buildcache_guard.py` pulls the `docker` build script out of the YAML with PyYAML and runs **its real branch logic** — only `buildctl`, `node` and `sudo` are stubbed — counting the arguments `buildctl` actually received: ``` refs/heads/master --export-cache x1 --import-cache x1 refs/tags/v0.2.0 --export-cache x0 --import-cache x1 refs/pull/1/head --export-cache x0 --import-cache x0 refs/heads/feat/some-branch --export-cache x0 --import-cache x0 OK: exactly one writer to :buildcache, on refs/heads/master ``` It then breaks the guard three ways and requires each break to be caught, so its failure path executes on **every** CI run: ``` self-test (breaking the guard on purpose): self-test rejected: guarded on event_name instead of the branch ref refs/heads/feat/some-branch: expected 0 --export-cache, got 1. self-test rejected: second writer on the tag build (the m365-mcp#4 shape) refs/tags/v0.2.0: expected 0 --export-cache, got 1. self-test rejected: guarded on refs/heads/main, which this repo does not have refs/heads/master: expected exactly 1 --export-cache, got 0. ``` Breaking the real `ci.yml` (`refs/heads/master` → `event_name != 'pull_request'`) takes the harness to exit 1, and restoring it returns exit 0. **Residual, known:** the guard makes the ref *classes* disjoint. Two pushes to `master` in quick succession would still overlap. A `concurrency:` group is not applied because Forgejo's support for it is unverified here and an unsupported key is ignored silently — which would look fixed and not be. The other five servers carry the same residual; m365-mcp#7 tracks it. ## Verification Everything below was run, not asserted. - `ruff`, `mypy --strict`, `pytest` (54 passed), and the guard, all green on Python 3.13 — the version CI and the image both install. - **Server actually serving.** Started in streamable-HTTP mode against a local stub Listmonk: `GET /health` → `200 {"status":"healthy","version":"0.2.0","revision":"..."}`; `GET /metrics` on the public listener → `404`; a real MCP session over `/mcp` doing `initialize`, `tools/list` (70 tools) and `tools/call`, returning stub data. `/metrics` on the internal listener showed the resulting counters. - **The image running.** `docker build`, then the container serving the same three checks, plus `docker stop` exiting **0** — which is what proves the two listeners shut down together. - **stdio unchanged**, verified with a stdio MCP client: `initialize`, 70 tools, a working `tools/call`. - **Every load-bearing assertion mutation-tested.** Twelve deliberate breaks — metrics defaulting to `0.0.0.0`, `POD_IP` ignored, non-cumulative buckets, label escaping removed, `/metrics` leaked onto the public app, error calls uncounted, tools not copied, per-session lifespan reintroduced, `/health` leaking the credential, listener failures swallowed, the surviving listener never asked to exit, silent cancellation — each turned the suite red, and each was reverted. ### Three defects found by running rather than compiling Two fail at container **start**, not at build, so a green build proves neither: - `uv sync` installs the project editable by default, leaving a `.pth` pointing at the builder's `/build/src` → `ModuleNotFoundError`. Fixed with `--no-editable`. - uv bakes an absolute interpreter path into every console-script shebang, so a venv built at `/build/.venv` and copied to `/app/.venv` exec's a path that does not exist. Fixed with `UV_PROJECT_ENVIRONMENT`. One found by reading the live `/metrics`: **an unregistered tool name arriving over the wire became a metric label.** Any client could grow the registry without bound, one label set per request, for the life of the process. Unregistered names now collapse to `__unknown__`; proven against the running server with 20 random names producing exactly one label. ### Cross-engine review `codex exec --sandbox read-only`, two specific questions. It confirmed the lifespan nesting order and confirmed the guard executes the workflow's real logic. It found three real shutdown defects, all fixed with tests that fail without the fix: - `lifespan` wrapped the `yield`, so every later failure — including one raised during session-manager shutdown — was logged as "Failed to start server". - `serve()` swallowed a failure from the listener that finished during the shutdown window; `gather(return_exceptions=True)` ate it. - A listener that never stopped was cancelled silently, abandoning graceful ASGI shutdown without saying so. ## Out of scope, deliberately No Listmonk API user or role was created and the live Listmonk was not touched — that credential's scope is Julian's decision. Local verification used a stub. No release tag pushed. `AGENTS.md` and `CLAUDE.md` added, per the repo convention: `CLAUDE.md` is the single line `@AGENTS.md`, and `AGENTS.md` holds the fork relationship, the `master`-not-`main` consequence, the deployment contract, the one-writer rule with its reasoning and residual, and the pitfalls above. `AGENTS.md` also records that `.forgejo/workflows` **shadows** `.github/workflows`: upstream's PyPI-publish and Pages-docs workflows do not run once `.forgejo/` is in the tree, and must not be deleted — deleting them buys nothing and costs fork divergence plus a merge conflict against upstream. Established by observation: `caldav-mcp` carries both, its `.github/workflows/ci.yml` declares jobs `quality` and `container`, and across all 35 task records there the only jobs that have ever run are `cargo` and `docker`. (An earlier revision of this branch claimed `forge.sh new` hardcodes `origin/main`. It no longer does — it reads `refs/remotes/origin/HEAD` with an `origin/main` then `origin/master` fallback. Corrected in `d7c6c73`.) --- ## Added after review, 2026-08-26 ### The buildcache guard passed against a workflow that never ran `scripts/check_buildcache_guard.py` greps for the literal `refs/heads/main`. The push trigger's filter is `branches: [master]`, which is not that string, so changing it to `branches: [main]` — the same copy-from-another-repo mistake one line higher in the same file — left the check **exit 0**, printing "OK: exactly one writer to :buildcache, on refs/heads/master". It is the worse of the two faults. The workflow never fires on `master`, so no image is built, no cache is written, and the guard itself stops running: the check that would have caught it is the thing the break disables. Found by breaking the check **two** ways rather than one. Break one, the export guard changed to `refs/heads/main`, was caught with four failures. Stopping there would have left the guard looking sound. ### Five cross-engine passes, five findings, all real Each fix invited the next, which is the argument for the last one: | pass | edit that passed exit 0 | effect | |---|---|---| | 1 | `branches: [main]` | workflow never fires on master | | 2 | `paths-ignore: ["**"]` | every master push filtered out | | 3 | `strategy: matrix` on `docker` | N concurrent exporters from one push | | 4 | `needs: nonexistent` | docker job never scheduled | | 5 | `runs-on: <label-no-runner-has>` | docker job never scheduled | Plus `if: false` on the `docker` job, on the build step, and on the guard step itself. Naming keys one at a time does not terminate, so the final check is fail-closed and **by value**: `on.push`, both job headers (everything outside `steps:`) and the two steps that must run unconditionally are compared to expected mappings. Anything new fails until a person has read it. Keys alone were not enough — `runs-on` keeps its key and schedules nothing. **Fifteen breaks now execute on every CI run**, up from three. **Where it stops.** A sixth pass proposed `exit 1` in the `Install buildctl` step. That does stop the build and turns the run **red**, which the CI status already reports and which blocks the merge. Every edit whose effect is a failing job is out of scope by construction; this file defends against a workflow reporting *success* while having built nothing or exported twice. The one edit that would convert red into green, `continue-on-error: true`, is refused by the job-header pin. ### The two API fixes are now observable `dc07b44` and `4fd84c2` were behaviour changes to the request body with nothing in the suite that could see them: all 54 tests imported `transport`, `metrics` or `server`, and none imported `client`. Neither fix changes a return value, so old and new code both succeed against a permissive server — the difference is only in the bytes on the wire. 12 tests against a stub HTTP server that records requests. Every named mutation was run: | mutation | red | |---|---| | `data = {"subscribers": subscribers}` (reverts dc07b44) | 3 tests | | `lists` passed through as objects, not ids | 1 | | drop the `"subscribers"` key from the merged payload | 1 | | `json_data={"value": value}` (reverts 4fd84c2) | 7 | | the same wrapper on the bulk `PUT /api/settings` | 1 | Cross-engine review then found two assertions pinning less than they read, both real, both fixed and both re-verified by mutation: - `assert recorded.json == value` conflates `True` with `1` and `False` with `0`. A client serialising booleans as integers stayed green on exactly the two cases where listmonk cares. Now asserts the raw bytes. - `stub.last("PUT", "/api/settings")` matched by substring, so `/api/settings/anything` satisfied it — a correct body sent to the wrong endpoint passed the per-key-versus-bulk distinction the test draws. Now matches the path exactly. Asserting raw bytes surfaced a limitation, pinned rather than fixed: httpx reads `json=None` as "no JSON body" rather than the JSON value `null`, so `update_setting(key, None)` sends an empty body. Whether any setting needs a null is a question for someone with the settings document in front of them. ### `AGENTS.md` "Until pull request #2, `master` **was** byte-identical to upstream" was past tense about something still true when written, and the cost is named in the paragraph it sits in: someone picks `master` as a base and ships upstream's server without the 41 tools. Now present tense and carrying its verification date. Break B is written down there too, so the finding survives even if this patch is later rewritten. ### Toolchain There is no `setup-python` step and no toolchain pin in `ci.yml` — this repo is the only one in the fleet without one. Checked rather than assumed: the pin is `.python-version` (3.13), uv honours it, and the Dockerfile pins `python:3.13-slim-bookworm` by digest, so CI and the image agree. `pyproject` declares `requires-python = ">=3.11"` and nothing exercises 3.11 at runtime; `mypy` is configured at 3.11, so the floor is type-checked but not run. 66 tests, `ruff`, `mypy --strict` and the guard all green locally on 3.13.
feat(deploy): streamable-HTTP transport, image and CI
All checks were successful
CI / python (pull_request) Successful in 17s
CI / docker (pull_request) Successful in 18s
3b65110365
listmonk-mcp could not be deployed: stdio only, no image, and no Forgejo
workflow had ever run. Closes jlxq0/listmonk-mcp#1.

Transport. `server.run()` with no argument is stdio, which cannot sit behind
an HTTPRoute. Adds `--transport streamable-http` / `LISTMONK_MCP_TRANSPORT`,
meeting the contract the other six servers meet: public listener on
0.0.0.0:3000 carrying /mcp and /health, metrics on a SEPARATE listener
resolving explicit env, then {POD_IP}:9090, then 127.0.0.1:9090, never
0.0.0.0. The split is not a stylistic choice — one listener would put
/metrics behind the same public hostname as /mcp, which is what the split
prevents. stdio keeps working and is still the default.

The Listmonk client is managed at the ASGI lifespan, not FastMCP's. FastMCP
threads its lifespan into the low-level Server, which the streamable-HTTP
session manager runs once per MCP *session* — so the client would connect and
disconnect per client session, and two concurrent sessions would race on the
module globals, the first to finish closing the client the second was using.

Metrics are hand-rolled Prometheus text rather than prometheus-client: the
exposition is a few dozen lines and a dependency in a locked, deployed image
costs more than maintaining them. Instrumentation lives in call_tool, the one
choke point every dispatch passes through, so it cannot be forgotten when a
tool is added.

Image. Two stages, both python:3.13-slim-bookworm pinned by digest;
distroless carries no Python interpreter. uv from its own digest-pinned
image, dependency layer split from source, non-root UID 65532 to match the
distroless `nonroot` the Rust servers use, BUILD_VERSION/REVISION/CREATED
OCI labels. Port 9090 deliberately not EXPOSEd.

CI. ruff, mypy (strict) and pytest, none of which has ever run here, plus
the buildcache guard. There was no tests/ directory at all; there are now 54
tests, and each load-bearing assertion was checked by breaking the code it
covers and watching it go red.

ONE WRITER TO :buildcache, guarded on refs/heads/master by name — this
repo's default branch is master, not main. The tag build imports and does
not export. `github.event_name != 'pull_request'` is not equivalent: it is
also true on a tag push, which is how typst-mcp had one export site and two
writers.

Verified by execution, because the three obvious checks do not work here: a
green docker job proves nothing (two overlapping exporters both pass), an
unchanged cache digest proves nothing (content-addressed, so a full hit
re-exports identical bytes), and grepping the job log proves nothing (log
retention is sporadic and 0 is also the correct answer).
scripts/check_buildcache_guard.py extracts the docker build script from the
YAML with PyYAML and runs its real branch logic under refs/heads/master,
refs/tags/v0.2.0, refs/pull/1/head and a feature branch, counting the
--export-cache arguments buildctl actually received: 1, 0, 0, 0. It then
breaks the guard three ways and requires each break to be caught, so its
failure path runs on every CI invocation rather than being assumed.

Two bugs found by running rather than compiling, both of which fail at
container start and not at build:
- uv installs the project editable by default, leaving a .pth pointing at
  the builder's /build/src. Fixed with --no-editable.
- uv bakes an absolute interpreter path into console-script shebangs, so a
  venv built at /build/.venv and copied to /app/.venv exec's a path that
  does not exist. Fixed with UV_PROJECT_ENVIRONMENT.

One found by reading the live /metrics: an unregistered tool name arriving
over the wire became a metric label, so any client could grow the registry
without bound. Unregistered names now collapse to a single label; proven
against the running server with 20 random names producing one label.

Cross-engine review (codex exec) found three real defects in shutdown
handling, all fixed with tests: lifespan reported every later failure as
"Failed to start server", serve() swallowed a listener failure that occurred
during the shutdown window, and a listener that never stopped was cancelled
silently. AGENTS.md and CLAUDE.md added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRcSvGZApc18CPyUtoyLNZ
docs(agents): correct two claims verified against the fleet
All checks were successful
CI / python (pull_request) Successful in 18s
CI / docker (pull_request) Successful in 11s
d7c6c73e7d
`forge.sh new` no longer hardcodes `origin/main`. It reads
`refs/remotes/origin/HEAD` and falls back to `origin/main` then
`origin/master` (bin/forge.sh:147-155 in mantis), so it can create a
worktree here. The stale warning is removed rather than softened.

Adds the constraint I nearly acted wrongly on: `.forgejo/workflows`
shadows `.github/workflows`, so upstream's PyPI-publish and Pages-docs
workflows do not run here and must NOT be deleted — deleting them buys
nothing and costs fork divergence plus a merge conflict against upstream.

Established by observation, not documentation, which is why it belongs in
this file: caldav-mcp carries both directories, its .github/workflows/ci.yml
declares jobs `quality` and `container`, and across all 35 task records on
that repository the only job names that have ever run are `cargo` and
`docker`. Verified against the API rather than taken on report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRcSvGZApc18CPyUtoyLNZ
"Until pull request #2, `master` was byte-identical to upstream" reads as
past tense about something still true: #2 is open, and `origin/master` and
`rhnvrm/listmonk-mcp@HEAD` are both `3e1cf0d` as of today.

The cost of the wrong tense is named in the paragraph it sits in — someone
picks `master` as a base and ships upstream's server without the 41 extra
tools, and nothing about the resulting image says so. Carries the verification
date, so the next reader knows how old the claim is rather than trusting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
`check_static` greps for the literal `refs/heads/main`. The push trigger's
filter is `branches: [master]`, which is not that string — so changing it to
`branches: [main]`, the same copy-from-another-repo mistake one line higher in
the same file, left the check passing green: exit 0, printing "OK: exactly one
writer to :buildcache, on refs/heads/master".

It is the worse of the two faults. The workflow never fires on `master`, so no
image is built, no cache is written, and the guard itself never runs. The check
that would have caught it is the thing the break disables, and its green is
evidence of nothing while reading as evidence of everything.

Found by breaking the check two ways rather than one. Break one — the export
guard changed to `refs/heads/main` — was caught, exit 1 with four failures.
Stopping there would have left the guard looking sound.

`check_static` now asserts `on.push.branches == ["master"]`, and three
workflow-level breaks join the three script-level ones in the self-test, so all
six execute on every CI run rather than sitting in a comment.

Reading the trigger has the same failure shape a third time: PyYAML follows
YAML 1.1, so the bare key `on:` parses as the boolean `True`. `document["on"]`
raises `KeyError` and `document.get("on", {})` returns an empty mapping and
passes. `push_branches` reads the `True` key and returns a describing string
rather than a default when the block is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
`dc07b44` and `4fd84c2` are behaviour changes to the request body the client
sends, verified by hand against a live instance when written and by nothing
since. They were invisible to the suite: the 54 existing tests import
`transport`, `metrics` or `server`, and none import `client`.

Neither fix changes a return value. Old and new code both succeed against a
permissive server, so the difference lives only in the bytes on the wire — the
stub records requests and the assertions read the recorded body, not the
client's return. The stub is deliberately permissive for the same reason:
one that validated the way listmonk does would make these pass by asserting a
fact about the stub.

Each assertion names the mutation that turns it red. Every one was run:

- `data = {"subscribers": subscribers}` (reverts dc07b44) — 3 red, and
  `test_recipients_survive_the_merge` correctly stays green, since the old
  code sent recipients too. Removing the `"subscribers"` key is that test's
  own named mutation, and turns it alone.
- `lists` passed through as objects — `test_lists_are_flattened_to_ids` alone.
- `json_data={"value": value}` (reverts 4fd84c2) — all 7 update_setting tests.
- the same wrapper on the bulk `PUT /api/settings` —
  `test_full_settings_put_still_sends_the_mapping` alone. The per-key fix
  loosened `_request`'s `json_data` to `Any`; this pins that the bulk path was
  not loosened with it.

The first mutation attempted was the wrong one: cutting from the
`get_campaign` call to the return deleted `test_campaign` entirely, because
`update_campaign` contains the same line 46 lines earlier. Everything went red
for a missing attribute, which would have read as the tests pinning far more
than they do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
Cross-engine review (Codex, read-only) against the previous commit: with
`on.push.branches` still reading exactly `[master]`, adding
`paths-ignore: ["**"]` filters out every push to master and the script exits 0.
Confirmed against the file. Same fault as `branches: [main]` wearing a
different key — the branch list is what the check reads, and it is not the only
thing that decides whether the workflow fires.

`if: false` on the `docker` job is the third instance. A job-level condition is
evaluated before any step, so the build never happens while every ref-level
assertion here passes: the script runs the extracted build script directly and
never asks whether the job carrying it was entered.

`check_static` now rejects `paths`, `paths-ignore`, `branches-ignore` and
`tags-ignore` under `on.push`, and an `if:` on either job. Two more self-test
cases, so eight breaks execute per CI run rather than three.

Filters are named rather than allow-listed: a new trigger key added by Forgejo
should be read by a person before it is trusted, and an allow-list would admit
it silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
Second cross-engine pass (Codex, read-only) on the previous commit:
`strategy: { matrix: { slot: [1, 2] } }` on the `docker` job passes every check
in this script and starts two concurrent instances per push, both exporting to
the same `:buildcache` ref. Confirmed against the file.

That is the original race — the one m365-mcp#4 cost an afternoon — reintroduced
from a direction none of the ref-level checks look. They execute the extracted
build script once per ref and count arguments; how many times the job carrying
it runs is not a question they ask.

Nine breaks now execute per CI run. If a matrix is ever genuinely wanted, the
cache ref has to be qualified per combination first, which the failure message
says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
Third cross-engine pass (Codex, read-only): `if: false` on the "Build (and push
on tag)" step skips the build entirely, and this script extracts that step's
`run:` block and executes it regardless, so it exits 0. Confirmed against the
file.

The same edit on the "buildcache guard" step is the shape one more time, at its
limit: it stops this file running on CI at all, and nothing else would report
it. It is now caught by reading the YAML rather than by running.

Not a blanket rule. The registry-login step carries
`if: github.event_name != 'pull_request'` legitimately, because a pull request
has no credentials to log in with, so only the two steps that must run
unconditionally are named. A rule broad enough to catch this without naming
them would fail on the workflow as it stands.

Eleven breaks execute per CI run. Three Codex passes, three findings, all three
real; the fourth pass is the one that decides whether this is done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
Fourth cross-engine pass (Codex, read-only): `needs: nonexistent` on the
`docker` job is never scheduled, so the build silently does not happen while
every check here passes. Confirmed against the file — `docker` does carry
`needs: python` today, so this is a value change and not a new key.

Four passes, four different keys, each fix inviting the next. Naming faults one
at a time does not terminate: the keys that decide whether a job runs, or how
many instances of it run, are an open set, and a Forgejo release can add one.

So the last check is fail-closed. `on.push`, both jobs and the two steps that
must run unconditionally may carry the keys they carry today and nothing else;
anything new fails until a person has read it and either allowed it or written
the specific check. `docker.needs` is pinned by value for the reason above.

The named checks stay, for what they explain rather than for what they catch —
the allow-list would reject all four with one generic message, and none of them
would teach the next reader why the key mattered.

Fourteen breaks execute per CI run, three of them the allow-list's own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
Fifth cross-engine pass (Codex, read-only): `runs-on: <a-label-no-runner-has>`
keeps every allowed key and schedules nothing. The allow-list closed which keys
may appear and said nothing about what they may say.

So `on.push`, both job headers (everything outside `steps:`) and the two
unconditional steps (everything outside `run:`) are now compared to expected
mappings by value. Fifteen breaks execute per CI run.

Five passes, five findings, all five real and all five the same shape: a
workflow that does not run looks exactly like a workflow that runs and passes,
from anywhere except the workflow itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
Sixth cross-engine pass proposed `exit 1` in the `Install buildctl` step. It
does stop the build, and it turns the run red — which the CI status reports and
which blocks the merge. Every edit whose effect is a failing job is out of
scope here by construction; this file defends against a workflow reporting
success while having built nothing or exported twice.

The one edit that would convert that red into a green, `continue-on-error:
true`, is already refused by the job-header pin.

Without a stated boundary the next pass finds a seventh edit and the file grows
a check for something the CI status already says out loud.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
test(client): tighten two assertions that pinned less than they read
All checks were successful
CI / python (pull_request) Successful in 22s
CI / docker (pull_request) Successful in 13s
9b19764050
Cross-engine review (Codex, read-only) of the previous test commit found both,
and both were real.

`assert recorded.json == value` conflates `True` with `1` and `False` with `0`,
because that is what Python's `==` does. A client serialising booleans as
integers stayed green on exactly the two cases where listmonk cares. Verified
by mutation: `int(value) if isinstance(value, bool) else value` turns the two
boolean cases red now and turned nothing red before.

`stub.last("PUT", "/api/settings")` matched by substring, so
`/api/settings/anything` satisfied it — a correct body sent to the wrong
endpoint passed the per-key-versus-bulk distinction the test exists to draw.
`last()` now matches the path exactly. Verified by mutation: retargeting the
bulk PUT at `/api/settings/all` turns it red.

Asserting the raw bytes surfaced a limitation worth recording: httpx reads
`json=None` as "no JSON body" rather than the JSON value `null`, so
`update_setting(key, None)` sends an empty body. Pinned in its own test as
current behaviour, not fixed — whether any setting needs a null is a question
for someone with the settings document in front of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfH9dv5Ub8c9PRxipsBXBe
jlxq0 changed target branch from feat/comprehensive-api-coverage to master 2026-08-26 05:18:07 +00:00
jlxq0 merged commit d74a7681f0 into master 2026-08-26 05:28:46 +00:00
Sign in to join this conversation.
No description provided.