deploy: listmonk-mcp has no HTTP transport, no image and no CI #1

Closed
opened 2026-08-25 04:01:17 +00:00 by jlxq0 · 1 comment
Owner

listmonk-mcp cannot be deployed today. It speaks stdio, there is no image, and CI has never run. Three pieces of work, in order, and then it goes to Clark for Renovate, manifests and ArgoCD.

What is actually there

Verified rather than recalled, 2026-08-25:

language Python 3.11+, hatchling, uv.lock present
MCP library mcp 1.11.0, using FastMCP from mcp.server (the SDK's bundled FastMCP, not the standalone fastmcp package)
transport src/listmonk_mcp/server.py calls server.run() with no argument, which is stdio
Dockerfile absent
CI only .github/workflows/publish.yml, inherited from rhnvrm/listmonk-mcp, publishing to PyPI. actions/tasks has total_count 0: no Forgejo workflow has ever run
registry forge.oddie.app/jlxq0 holds caldav, carddav, jmap, m365, matrix and typst. No listmonk-mcp
default branch master, not main, unlike the rest of the fleet

mcp 1.11.0 supports transport="streamable-http". No dependency bump is needed for piece 1.

Read this before choosing a base branch

origin/master is byte-identical to upstream/master. Every change Julian has made to this fork sits unmerged on feat/comprehensive-api-coverage:

4661545 feat: comprehensive API coverage — 30 new tools
dc07b44 fix(test_campaign): send full campaign payload, not just subscribers
4fd84c2 fix(update_setting): send raw JSON value, not wrapped in {"value": ...}

1192 insertions across client.py and server.py, dated 2026-05-06 and 2026-05-07, with no pull request open for any of it.

So a deployment built on master ships upstream's server without the thirty tools. Land that branch first, in its own pull request, before building anything on top. That is piece 0 and it is not optional.

It also settles the fork question the deployment work has to answer. The fork carries zero divergence from upstream on master today, and the divergence that exists is unlanded. A streamable-HTTP transport is small and genuinely upstreamable. The thirty tools are a larger conversation. Say in each pull request which of the two it is, rather than letting the fork accumulate carried patches by default.

1. Streamable-HTTP transport

A server behind an HTTPRoute has to speak HTTP. stdio cannot be routed.

The deployment contract the other six servers already meet, read out of caldav-mcp:

  • public listener on 0.0.0.0:3000, carrying the MCP endpoint at /mcp plus a health endpoint
  • metrics on a separate listener at :9090, resolved as explicit env, then {POD_IP}:9090, then 127.0.0.1:9090. It must never default to 0.0.0.0
  • bind address overridable by environment variable

Match that contract. Do not invent a different one, and do not skip the metrics split because Python makes one listener easier: the reason it is separate is that /metrics must not be publicly routable, and that reason does not depend on the language.

Keep stdio working. It is how the server is used from a local client today, and removing it to add HTTP trades one deployment for another.

2. Dockerfile

caldav-mcp's is the reference for structure: pinned base by digest, dependencies cached in a layer separate from source, non-root runtime, OCI labels fed by BUILD_VERSION, BUILD_REVISION and BUILD_CREATED build args. Distroless will not carry a Python runtime, so the runtime stage differs; everything else should not.

3. Forgejo Actions workflow

Building and pushing to forge.oddie.app/jlxq0/listmonk-mcp, via buildctl against the runner's buildkitd, as the other five do.

One writer to the buildcache ref, from the first commit. m365-mcp#4 is the whole story: two docker jobs exporting to one unqualified :buildcache ref, one losing the blob write, failing a run whose image was already published. Fixed across the other five today. The shape is:

IMPORT_CACHE_ARG=()
EXPORT_CACHE_ARG=()
IMPORT_CACHE_ARG=(--import-cache "type=registry,ref=${CACHE_REF}")
if [[ "$GITHUB_REF" == refs/heads/main ]]; then     # or master, for this repo
  EXPORT_CACHE_ARG=(--export-cache "type=registry,ref=${CACHE_REF},mode=max")
fi

Guard the export on the branch ref specifically, not on != pull_request. typst-mcp had exactly one export site guarded that way and still had two writers, because that condition is true on a tag push. Follow-up paths that remain open are in m365-mcp#7.

How to verify it, given what does not work here

Three things this fleet learned today, each of which will otherwise waste an afternoon:

  • A green docker job proves nothing about cache behaviour. caldav-mcp tasks 16484 and 16485 overlapped, both exporting to one ref, and both passed.
  • An unchanged :buildcache manifest digest proves nothing either. The cache manifest is content-addressed, so a full cache hit re-exports identical bytes to an identical digest. jmap-mcp 16533 exported and left the digest untouched.
  • Job logs are retained sporadically. Of ten jobs across four merges today, one wrote a .log.zst. grep -c "exporting cache to registry" returns 0 when the log is missing, and 0 is the answer that means "correct".

What does work is executing the workflow's own branch logic. Extract the docker build script from the YAML with PyYAML, run it under refs/heads/master, refs/tags/v0.2.0, refs/pull/1/head and one other branch, and assert the export-argument count is non-zero only for the first. Then break the guard deliberately and watch it go red before trusting it.

Out of scope

Do not create a Listmonk API user or role. That is a change to a service Julian uses and the scope is his decision. Clark established that campaigns:send is distinct from campaigns:manage in Listmonk v6.1.0, so a credential that can draft but not send is a real option. Whichever he picks, it arrives through an ExternalSecret from 1Password, never an env value in a manifest and never a .env.

`listmonk-mcp` cannot be deployed today. It speaks stdio, there is no image, and CI has never run. Three pieces of work, in order, and then it goes to Clark for Renovate, manifests and ArgoCD. ## What is actually there Verified rather than recalled, 2026-08-25: | | | |---|---| | language | Python 3.11+, `hatchling`, `uv.lock` present | | MCP library | `mcp` 1.11.0, using `FastMCP` from `mcp.server` (the SDK's bundled FastMCP, not the standalone `fastmcp` package) | | transport | `src/listmonk_mcp/server.py` calls `server.run()` with no argument, which is stdio | | Dockerfile | absent | | CI | only `.github/workflows/publish.yml`, inherited from `rhnvrm/listmonk-mcp`, publishing to PyPI. `actions/tasks` has `total_count` 0: no Forgejo workflow has ever run | | registry | `forge.oddie.app/jlxq0` holds caldav, carddav, jmap, m365, matrix and typst. No `listmonk-mcp` | | default branch | `master`, not `main`, unlike the rest of the fleet | **`mcp` 1.11.0 supports `transport="streamable-http"`.** No dependency bump is needed for piece 1. ## Read this before choosing a base branch `origin/master` is **byte-identical to `upstream/master`**. Every change Julian has made to this fork sits unmerged on `feat/comprehensive-api-coverage`: ``` 4661545 feat: comprehensive API coverage — 30 new tools dc07b44 fix(test_campaign): send full campaign payload, not just subscribers 4fd84c2 fix(update_setting): send raw JSON value, not wrapped in {"value": ...} ``` 1192 insertions across `client.py` and `server.py`, dated 2026-05-06 and 2026-05-07, with no pull request open for any of it. **So a deployment built on `master` ships upstream's server without the thirty tools.** Land that branch first, in its own pull request, before building anything on top. That is piece 0 and it is not optional. It also settles the fork question the deployment work has to answer. The fork carries *zero* divergence from upstream on `master` today, and the divergence that exists is unlanded. A streamable-HTTP transport is small and genuinely upstreamable. The thirty tools are a larger conversation. Say in each pull request which of the two it is, rather than letting the fork accumulate carried patches by default. ## 1. Streamable-HTTP transport A server behind an HTTPRoute has to speak HTTP. stdio cannot be routed. The deployment contract the other six servers already meet, read out of `caldav-mcp`: - public listener on `0.0.0.0:3000`, carrying the MCP endpoint at `/mcp` plus a health endpoint - metrics on a **separate** listener at `:9090`, resolved as explicit env, then `{POD_IP}:9090`, then `127.0.0.1:9090`. It must never default to `0.0.0.0` - bind address overridable by environment variable Match that contract. Do not invent a different one, and do not skip the metrics split because Python makes one listener easier: the reason it is separate is that `/metrics` must not be publicly routable, and that reason does not depend on the language. Keep stdio working. It is how the server is used from a local client today, and removing it to add HTTP trades one deployment for another. ## 2. Dockerfile `caldav-mcp`'s is the reference for structure: pinned base by digest, dependencies cached in a layer separate from source, non-root runtime, OCI labels fed by `BUILD_VERSION`, `BUILD_REVISION` and `BUILD_CREATED` build args. Distroless will not carry a Python runtime, so the runtime stage differs; everything else should not. ## 3. Forgejo Actions workflow Building and pushing to `forge.oddie.app/jlxq0/listmonk-mcp`, via `buildctl` against the runner's buildkitd, as the other five do. **One writer to the buildcache ref, from the first commit.** m365-mcp#4 is the whole story: two `docker` jobs exporting to one unqualified `:buildcache` ref, one losing the blob write, failing a run whose image was already published. Fixed across the other five today. The shape is: ```bash IMPORT_CACHE_ARG=() EXPORT_CACHE_ARG=() IMPORT_CACHE_ARG=(--import-cache "type=registry,ref=${CACHE_REF}") if [[ "$GITHUB_REF" == refs/heads/main ]]; then # or master, for this repo EXPORT_CACHE_ARG=(--export-cache "type=registry,ref=${CACHE_REF},mode=max") fi ``` Guard the export on the **branch ref specifically**, not on `!= pull_request`. typst-mcp had exactly one export site guarded that way and still had two writers, because that condition is true on a tag push. Follow-up paths that remain open are in m365-mcp#7. ## How to verify it, given what does not work here Three things this fleet learned today, each of which will otherwise waste an afternoon: - **A green `docker` job proves nothing about cache behaviour.** caldav-mcp tasks `16484` and `16485` overlapped, both exporting to one ref, and both passed. - **An unchanged `:buildcache` manifest digest proves nothing either.** The cache manifest is content-addressed, so a full cache hit re-exports identical bytes to an identical digest. jmap-mcp `16533` exported and left the digest untouched. - **Job logs are retained sporadically.** Of ten jobs across four merges today, one wrote a `.log.zst`. `grep -c "exporting cache to registry"` returns `0` when the log is missing, and `0` is the answer that means "correct". What does work is executing the workflow's own branch logic. Extract the `docker` build script from the YAML with PyYAML, run it under `refs/heads/master`, `refs/tags/v0.2.0`, `refs/pull/1/head` and one other branch, and assert the export-argument count is non-zero only for the first. Then break the guard deliberately and watch it go red before trusting it. ## Out of scope **Do not create a Listmonk API user or role.** That is a change to a service Julian uses and the scope is his decision. Clark established that `campaigns:send` is distinct from `campaigns:manage` in Listmonk v6.1.0, so a credential that can draft but not send is a real option. Whichever he picks, it arrives through an ExternalSecret from 1Password, never an env value in a manifest and never a `.env`.
Author
Owner

Two things that are no longer true in the description above, recorded rather than quietly skipped.

"CI has never run" and "actions/tasks total_count 0" are stale. Six tasks have run since, four of them on #3. python and docker were both green on d7c6c73.

Piece 0 is done. #2 merged at 3b80c11, so master now carries the 41 tools and 1192 lines of divergence from rhnvrm/listmonk-mcp. #3 is retargeted to master and closes this issue.

One thing that is true and worth stating before anyone reads a merge as a rollout. listmonk-mcp has no Kubernetes namespace at all — it is the one MCP server in this fleet not deployed. So a release tag here does not produce a rollout the way it does everywhere else: there is no image in forge.oddie.app/jlxq0 to bump, nothing for Renovate to open a PR against, and no ArgoCD Application to sync. Standing that up the first time is infrastructure in oddie-apps/platform and it is Clark's.

Merging #3 gives a repository that builds an image. Tagging gives an image in the registry. Neither gives a server answering in production, and the distance between the tag and that is the part this issue does not cover.

Two things that are no longer true in the description above, recorded rather than quietly skipped. **"CI has never run" and "`actions/tasks` `total_count` 0" are stale.** Six tasks have run since, four of them on #3. `python` and `docker` were both green on `d7c6c73`. **Piece 0 is done.** #2 merged at `3b80c11`, so `master` now carries the 41 tools and 1192 lines of divergence from `rhnvrm/listmonk-mcp`. #3 is retargeted to `master` and closes this issue. **One thing that is true and worth stating before anyone reads a merge as a rollout.** `listmonk-mcp` has no Kubernetes namespace at all — it is the one MCP server in this fleet not deployed. So a release tag here does not produce a rollout the way it does everywhere else: there is no image in `forge.oddie.app/jlxq0` to bump, nothing for Renovate to open a PR against, and no ArgoCD Application to sync. Standing that up the first time is infrastructure in `oddie-apps/platform` and it is Clark's. Merging #3 gives a repository that builds an image. Tagging gives an image in the registry. Neither gives a server answering in production, and the distance between the tag and that is the part this issue does not cover.
jlxq0 closed this issue 2026-08-26 05:28:46 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/listmonk-mcp#1
No description provided.