feat(deploy): streamable-HTTP transport, Dockerfile and Forgejo CI #3
No reviewers
Labels
No labels
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
waiting-on-julian
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/listmonk-mcp!3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "http-transport"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #1.
listmonk-mcpcould 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 at3b80c11, somasternow 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/jlxq0registry and the:buildcacheguard are this fleet's deployment shape and mean nothing upstream.The one genuinely upstreamable piece is the streamable-HTTP transport itself —
mcp1.11.0 already supports it, no dependency bump — but as written it is entangled with the metrics split. Sending it up would mean extractingtransport.pyminus 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.0.0.0.0:3000/mcp,/healthLISTMONK_MCP_BIND_ADDR127.0.0.1:9090/metricsLISTMONK_MCP_METRICS_BIND_ADDRThe metrics listener resolves explicit env →
{POD_IP}:9090→127.0.0.1:9090, and never0.0.0.0. One listener would be less code and would put/metricsbehind 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
lifespaninto the low-levelServer, 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 incall_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-bookwormpinned by digest. Distroless carries no Python interpreter, so the runtime stage differs fromcaldav-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 distrolessnonrootthe Rust servers use, andBUILD_VERSION/BUILD_REVISION/BUILD_CREATEDfeed the OCI labels. Port 9090 is deliberately notEXPOSEd.3. Forgejo Actions workflow
ruff,mypy(strict = true) andpytest, none of which has ever run here, thenbuildctlagainst the runner's buildkitd. There was notests/directory at all; there are now 54 tests.Exactly one writer to
:buildcacheGuarded on
refs/heads/masterby name — this repo's default branch ismaster, notmain, and a copiedmainguard 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 howtypst-mcphad one export site and two writers.Verified by execution, because the three obvious checks do not work here:
dockerjob proves nothing. caldav-mcp16484and16485overlapped, both exported to one ref, both passed.:buildcachedigest proves nothing. The manifest is content-addressed, so a full hit re-exports identical bytes to an identical digest.0is also the answer that means correct.scripts/check_buildcache_guard.pypulls thedockerbuild script out of the YAML with PyYAML and runs its real branch logic — onlybuildctl,nodeandsudoare stubbed — counting the argumentsbuildctlactually received:It then breaks the guard three ways and requires each break to be caught, so its failure path executes on every CI run:
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
masterin quick succession would still overlap. Aconcurrency: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.GET /health→200 {"status":"healthy","version":"0.2.0","revision":"..."};GET /metricson the public listener →404; a real MCP session over/mcpdoinginitialize,tools/list(70 tools) andtools/call, returning stub data./metricson the internal listener showed the resulting counters.docker build, then the container serving the same three checks, plusdocker stopexiting 0 — which is what proves the two listeners shut down together.initialize, 70 tools, a workingtools/call.0.0.0.0,POD_IPignored, non-cumulative buckets, label escaping removed,/metricsleaked onto the public app, error calls uncounted, tools not copied, per-session lifespan reintroduced,/healthleaking 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 syncinstalls the project editable by default, leaving a.pthpointing at the builder's/build/src→ModuleNotFoundError. Fixed with--no-editable./build/.venvand copied to/app/.venvexec's a path that does not exist. Fixed withUV_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:lifespanwrapped theyield, 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.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.mdandCLAUDE.mdadded, per the repo convention:CLAUDE.mdis the single line@AGENTS.md, andAGENTS.mdholds the fork relationship, themaster-not-mainconsequence, the deployment contract, the one-writer rule with its reasoning and residual, and the pitfalls above.AGENTS.mdalso records that.forgejo/workflowsshadows.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-mcpcarries both, its.github/workflows/ci.ymldeclares jobsqualityandcontainer, and across all 35 task records there the only jobs that have ever run arecargoanddocker.(An earlier revision of this branch claimed
forge.sh newhardcodesorigin/main. It no longer does — it readsrefs/remotes/origin/HEADwith anorigin/mainthenorigin/masterfallback. Corrected ind7c6c73.)Added after review, 2026-08-26
The buildcache guard passed against a workflow that never ran
scripts/check_buildcache_guard.pygreps for the literalrefs/heads/main. Thepush trigger's filter is
branches: [master], which is not that string, sochanging it to
branches: [main]— the same copy-from-another-repo mistake oneline 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 noimage 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. Stoppingthere 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:
branches: [main]paths-ignore: ["**"]strategy: matrixondockerneeds: nonexistentruns-on: <label-no-runner-has>Plus
if: falseon thedockerjob, on the build step, and on the guard stepitself.
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 outsidesteps:) andthe 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-onkeeps its key and schedules nothing.Fifteen breaks now execute on every CI run, up from three.
Where it stops. A sixth pass proposed
exit 1in theInstall buildctlstep. 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, isrefused by the job-header pin.
The two API fixes are now observable
dc07b44and4fd84c2were behaviour changes to the request body with nothingin the suite that could see them: all 54 tests imported
transport,metricsor
server, and none importedclient. Neither fix changes a return value, soold 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:
data = {"subscribers": subscribers}(revertsdc07b44)listspassed through as objects, not ids"subscribers"key from the merged payloadjson_data={"value": value}(reverts4fd84c2)PUT /api/settingsCross-engine review then found two assertions pinning less than they read, both
real, both fixed and both re-verified by mutation:
assert recorded.json == valueconflatesTruewith1andFalsewith0. A client serialising booleans as integers stayed green on exactly thetwo cases where listmonk cares. Now asserts the raw bytes.
stub.last("PUT", "/api/settings")matched by substring, so/api/settings/anythingsatisfied it — a correct body sent to the wrongendpoint 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=Noneas "no JSON body" rather than the JSON valuenull, soupdate_setting(key, None)sends an empty body. Whether any setting needs anull is a question for someone with the settings document in front of them.
AGENTS.md"Until pull request #2,
masterwas byte-identical to upstream" was pasttense about something still true when written, and the cost is named in the
paragraph it sits in: someone picks
masteras a base and ships upstream'sserver 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-pythonstep and no toolchain pin inci.yml— this repo isthe only one in the fleet without one. Checked rather than assumed: the pin is
.python-version(3.13), uv honours it, and the Dockerfile pinspython:3.13-slim-bookwormby digest, so CI and the image agree.pyprojectdeclares
requires-python = ">=3.11"and nothing exercises 3.11 at runtime;mypyis configured at 3.11, so the floor is type-checked but not run.66 tests,
ruff,mypy --strictand the guard all green locally on 3.13.`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_01EfH9dv5Ub8c9PRxipsBXBeSecond 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_01EfH9dv5Ub8c9PRxipsBXBeif:on the build and guard steps a335b42f87Cross-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