Compare commits

..
Author SHA1 Message Date
renovate 768c9594b0 chore(deps): update docker.io/library/python docker tag to v3.15
renovate/stability-days Updates have met minimum release age requirement
Docker Build and Push / test (pull_request) Successful in 5m6s
PR Checks / hadolint (pull_request) Successful in 23s
Docker Build and Push / integration-test (pull_request) Successful in 5m26s
PR Checks / build-test (pull_request) Failing after 2m19s
PR Checks / pytest (pull_request) Successful in 5m57s
Docker Build and Push / build (pull_request) Failing after 1m41s
2026-10-18 19:27:12 +00:00
jcabillot 2e247141fc Merge pull request 'ci(tag): déclenche Renovate sur tf-depl-kubernetes dès que l'image de release est poussée (mcp-maildir)' (#62) from ci/renovate-dispatch-on-release into main
Main Release / test (push) Successful in 4m57s
Docker Build and Push / integration-test (push) Successful in 5m53s
Renovate / renovate (push) Successful in 1m32s
Main Release / integration-test (push) Successful in 7m25s
Docker Build and Push / build (push) Successful in 6m22s
Main Release / build (push) Successful in 6m13s
Main Release / tag (push) Successful in 20s
Tag Release / integration-test (push) Successful in 4m25s
Tag Release / build-push (push) Successful in 4m11s
Tag Release / trigger-renovate (push) Failing after 20s
Main Release / hadolint (push) Successful in 37s
Docker Build and Push / test (push) Successful in 4m47s
Tag Release / hadolint (push) Successful in 37s
Tag Release / test (push) Successful in 3m21s
Reviewed-on: #62
2026-10-18 14:42:34 -04:00
opencodecabilloteu ef480dfd97 ci(tag): dispatch Renovate on tf-depl-kubernetes once the release image is pushed
PR Checks / hadolint (pull_request) Successful in 28s
PR Checks / pytest (pull_request) Successful in 7m14s
PR Checks / build-test (pull_request) Successful in 5m15s
Docker Build and Push / test (pull_request) Successful in 3m40s
Docker Build and Push / integration-test (pull_request) Successful in 3m54s
Docker Build and Push / build (pull_request) Successful in 3m4s
A release only reached perso_private/tf-depl-kubernetes on the next hourly
Renovate scan, so the image bump PR could lag up to an hour behind the tag.

After the image push completes, dispatch that repo's Renovate workflow through
the Gitea API (workflow_dispatch is already enabled there). It authenticates
with the RENOVATE_TOKEN org secret of this organisation — the same token the
Renovate instance itself uses, no extra credential. The job fails loudly so a
rotated token shows up instead of the trigger silently going missing.
2026-10-18 13:22:32 -04:00
jcabillot 1115db82ed Merge pull request 'ci: harden the qdrant integration job (5s client timeout + cold storage)' (#60) from ci/harden-qdrant-integration into main
Main Release / test (push) Successful in 2m54s
Docker Build and Push / integration-test (push) Successful in 3m12s
Main Release / integration-test (push) Successful in 3m17s
Main Release / build (push) Successful in 21s
Renovate / renovate (push) Successful in 59s
Main Release / tag (push) Successful in 17s
Docker Build and Push / build (push) Successful in 4m25s
Tag Release / integration-test (push) Successful in 2m35s
Tag Release / build-push (push) Successful in 3m4s
Main Release / hadolint (push) Successful in 17s
Docker Build and Push / test (push) Successful in 3m0s
Tag Release / hadolint (push) Successful in 24s
Tag Release / test (push) Successful in 2m42s
Reviewed-on: #60
2026-10-17 17:42:56 -04:00
jcabillot dd41603a0b Merge pull request 'fix: serve MCP over Streamable HTTP instead of legacy SSE' (#58) from fix/streamable-http-transport into main
Main Release / build (push) Canceled after 0s
Main Release / tag (push) Canceled after 0s
Main Release / integration-test (push) Canceled after 1m8s
Main Release / test (push) Canceled after 1m10s
Renovate / renovate (push) Successful in 1m32s
Docker Build and Push / build (push) Skipped
Main Release / hadolint (push) Canceled after 1m12s
Docker Build and Push / test (push) Successful in 2m14s
Docker Build and Push / integration-test (push) Failing after 11m3s
Reviewed-on: #58
2026-10-17 17:42:33 -04:00
Sagent 01d15c486e ci: harden the qdrant integration job
PR Checks / hadolint (pull_request) Successful in 23s
Docker Build and Push / test (pull_request) Successful in 5m19s
PR Checks / build-test (pull_request) Successful in 47s
Docker Build and Push / integration-test (pull_request) Successful in 5m30s
PR Checks / pytest (pull_request) Successful in 3m7s
Docker Build and Push / build (pull_request) Successful in 3m14s
The integration test job has been failing intermittently on main and on
open pull requests with `httpx.ReadTimeout: timed out` on the first write
to Qdrant, while `GET /collections` and `/healthz` kept answering.

Cause: the qdrant-client default request timeout is 5 seconds, and the job
only waits for `/healthz`, which is served before the storage engine
accepts writes. On a shared runner doing several builds at once, the first
collection creation or indexing exceeds 5s and the whole fixture errors
out.

- pin qdrant/qdrant to v1.19 (the digest `latest` and the cluster already
  run) and let Renovate track the pin in the workflows as well
- wait for a real write round-trip (create + delete a probe collection)
  instead of `/healthz` alone, and dump the container logs if it never
  becomes writable
- make the Qdrant client timeout configurable via QDRANT_TIMEOUT and set
  it to 60s in CI, where there is no latency SLA
2026-10-17 21:24:18 +00:00
jcabillot e883a791fb Merge pull request 'fix(indexer): restore module-level embedding/batch constants (cronjobs cassés)' (#59) from fix/indexer-missing-constants into main
Docker Build and Push / build (push) Skipped
Main Release / test (push) Successful in 2m25s
Main Release / integration-test (push) Successful in 2m32s
Main Release / build (push) Successful in 3m56s
Main Release / tag (push) Successful in 47s
Renovate / renovate (push) Failing after 14m44s
Tag Release / integration-test (push) Failing after 3m3s
Tag Release / build-push (push) Skipped
Docker Build and Push / integration-test (push) Failing after 5m58s
Main Release / hadolint (push) Successful in 50s
Docker Build and Push / test (push) Successful in 5m1s
Tag Release / hadolint (push) Successful in 19s
Tag Release / test (push) Successful in 3m17s
Reviewed-on: #59
2026-10-17 17:11:51 -04:00
Sagent 3b575bbf83 fix(indexer): restore module-level embedding/batch constants
PR Checks / hadolint (pull_request) Successful in 12s
Docker Build and Push / test (pull_request) Successful in 2m17s
Docker Build and Push / integration-test (pull_request) Successful in 2m38s
PR Checks / pytest (pull_request) Successful in 2m19s
PR Checks / build-test (pull_request) Successful in 3m5s
Docker Build and Push / build (pull_request) Successful in 2m39s
The "make indexer importable without env" refactor (e32806a, merged via PR
#52 on 2026-10-09) removed EMBEDDING_MODEL_NAME, BATCH_SIZE and
EMBEDDING_BATCH_SIZE from the module scope while main() kept referencing
them, so every indexer run died before reaching Qdrant:

    File "/app/src/indexer.py", line 264, in main
        print(f"Loading embedding model: {EMBEDDING_MODEL_NAME}...")
    NameError: name 'EMBEDDING_MODEL_NAME' is not defined

CI never caught it because no test executes main(). Both *-indexer cronjobs
have been failing (BackoffLimitExceeded) since 2026-10-16, i.e. the first
run after the image pin moved to v0.0.22-latest.

- restore the three constants with their pre-refactor defaults
- cover them in tests/test_indexer.py and cross-check that the indexer
  embeds with the same model as server.py (vectors must stay comparable)
- add a ruff F821 gate to the unit job so undefined names fail CI

Fixes mcp-maildir-cabilloteu-indexer and mcp-maildir-gmail-indexer.
2026-10-17 21:04:34 +00:00
Sagent bc3deb8676 fix: serve MCP over Streamable HTTP instead of legacy SSE
PR Checks / hadolint (pull_request) Successful in 40s
PR Checks / build-test (pull_request) Successful in 3m19s
PR Checks / pytest (pull_request) Successful in 2m47s
Docker Build and Push / test (pull_request) Successful in 3m1s
Docker Build and Push / integration-test (pull_request) Successful in 3m24s
Docker Build and Push / build (pull_request) Successful in 3m4s
Current MCP clients (OpenCode, Claude, ...) only speak Streamable HTTP: they
POST JSON-RPC to the configured URL, so a legacy SSE endpoint answers
405 Method Not Allowed and not a single tool can be reached.

Run FastMCP's http_app (build_app()) under uvicorn instead of mcp.run(sse):
the MCP endpoint is now /mcp, and the custom GET /health route keeps serving
the Kubernetes probes.

Add transport tests asserting /mcp and /health are exposed and /sse is gone.
2026-10-17 20:45:26 +00:00
jcabillot 4d9bd4c470 Merge pull request 'chore(deps): update dependency python to 3.14' (#54) from renovate/python-3.x into main
Main Release / test (push) Successful in 3m26s
Main Release / integration-test (push) Successful in 3m37s
Renovate / renovate (push) Successful in 1m29s
Main Release / build (push) Successful in 46s
Docker Build and Push / build (push) Successful in 4m11s
Main Release / tag (push) Successful in 42s
Tag Release / integration-test (push) Successful in 3m9s
Tag Release / build-push (push) Successful in 3m43s
Docker Build and Push / integration-test (push) Successful in 3m47s
Docker Build and Push / test (push) Successful in 3m29s
Main Release / hadolint (push) Successful in 38s
Tag Release / test (push) Successful in 2m54s
Tag Release / hadolint (push) Failing after 14m10s
Reviewed-on: #54
2026-10-12 15:56:35 -04:00
renovate cc74f384bf chore(deps): update dependency python to 3.14
renovate/stability-days Updates have met minimum release age requirement
PR Checks / hadolint (pull_request) Successful in 18s
Docker Build and Push / test (pull_request) Successful in 2m7s
PR Checks / pytest (pull_request) Successful in 2m8s
Docker Build and Push / integration-test (pull_request) Successful in 2m19s
PR Checks / build-test (pull_request) Successful in 2m23s
Docker Build and Push / build (pull_request) Successful in 1m49s
2026-10-09 21:10:08 +00:00
jcabillot 52642f9d99 Merge pull request 'chore(deps): update dependency fastmcp to v4' (#48) from renovate/fastmcp-4.x into main
Renovate / renovate (push) Successful in 53s
Main Release / test (push) Successful in 1m55s
Main Release / integration-test (push) Successful in 2m5s
Main Release / build (push) Successful in 2m39s
Docker Build and Push / build (push) Successful in 3m10s
Main Release / tag (push) Successful in 18s
Tag Release / integration-test (push) Successful in 1m45s
Tag Release / build-push (push) Successful in 2m46s
Main Release / hadolint (push) Successful in 20s
Docker Build and Push / test (push) Successful in 2m25s
Docker Build and Push / integration-test (push) Successful in 2m23s
Tag Release / hadolint (push) Successful in 14s
Tag Release / test (push) Successful in 1m40s
Reviewed-on: #48
2026-10-09 16:18:09 -04:00
renovate e7c39135ab chore(deps): update dependency fastmcp to v4
renovate/stability-days Updates have met minimum release age requirement
Docker Build and Push / test (pull_request) Successful in 2m21s
PR Checks / hadolint (pull_request) Successful in 33s
PR Checks / pytest (pull_request) Successful in 2m23s
Docker Build and Push / integration-test (pull_request) Successful in 2m55s
PR Checks / build-test (pull_request) Successful in 3m32s
Docker Build and Push / build (pull_request) Successful in 3m6s
2026-10-09 20:04:04 +00:00
jcabillot 916850b692 Merge pull request 'test: run pytest in CI — real unit job + Qdrant-backed integration job' (#52) from tests/ci-pytest into main
Main Release / test (push) Successful in 4m38s
Main Release / integration-test (push) Successful in 3m14s
Renovate / renovate (push) Successful in 2m25s
Docker Build and Push / build (push) Successful in 3m51s
Main Release / build (push) Successful in 4m8s
Main Release / tag (push) Successful in 52s
Tag Release / integration-test (push) Successful in 2m38s
Tag Release / build-push (push) Successful in 3m9s
Main Release / hadolint (push) Successful in 2m18s
Docker Build and Push / test (push) Successful in 4m50s
Docker Build and Push / integration-test (push) Successful in 5m19s
Tag Release / hadolint (push) Successful in 30s
Tag Release / test (push) Successful in 2m25s
Reviewed-on: #52
2026-10-09 15:53:52 -04:00
OpenCode Cabillot 679d72288f test: fix collection name mismatch — env wiring centralized in conftest
PR Checks / hadolint (pull_request) Successful in 18s
PR Checks / pytest (pull_request) Successful in 1m59s
Docker Build and Push / test (pull_request) Successful in 2m7s
Docker Build and Push / integration-test (pull_request) Successful in 2m14s
PR Checks / build-test (pull_request) Successful in 2m28s
Docker Build and Push / build (pull_request) Successful in 1m50s
Root cause of the CI 404s: test modules mutated os.environ at import time
(test_server.py set COLLECTION_NAME='test', test_integration.py
'test_mcp_maildir'). With pytest's alphabetical import order,
server.COLLECTION_NAME froze at whichever value was set when server was
first imported, while the fixture read os.environ at runtime — often a
different name (log proof: fixture created 'test', server queried
'test_mcp_maildir' → 404 on all 6 tests).

conftest.py now sets QDRANT_URL/COLLECTION_NAME/EMBEDDING_MODEL_NAME once,
before any test module import; env mutation removed from test files.
Unit suite still green (43 passed, 6 deselected).
2026-10-09 12:42:21 +00:00
OpenCode Cabillot ef8fe35da7 test: introspect http client base_url safely
PR Checks / hadolint (pull_request) Successful in 23s
PR Checks / build-test (pull_request) Successful in 34s
Docker Build and Push / test (pull_request) Successful in 2m17s
Docker Build and Push / integration-test (pull_request) Failing after 2m25s
PR Checks / pytest (pull_request) Successful in 1m50s
Docker Build and Push / build (pull_request) Skipped
2026-10-09 12:35:55 +00:00
OpenCode Cabillot 6997326b81 test: fix fixture debug attribute (QdrantClient has no rest_uri)
PR Checks / hadolint (pull_request) Successful in 17s
Docker Build and Push / test (pull_request) Successful in 2m2s
Docker Build and Push / integration-test (pull_request) Failing after 2m4s
PR Checks / pytest (pull_request) Successful in 1m48s
PR Checks / build-test (pull_request) Successful in 2m24s
Docker Build and Push / build (pull_request) Skipped
2026-10-09 12:27:53 +00:00
OpenCode Cabillot a597ecf6a2 test: instrument qdrant_setup fixture (log url + collections before/after create)
PR Checks / hadolint (pull_request) Successful in 18s
Docker Build and Push / test (pull_request) Successful in 2m32s
Docker Build and Push / integration-test (pull_request) Failing after 2m26s
PR Checks / pytest (pull_request) Successful in 2m0s
PR Checks / build-test (pull_request) Successful in 2m33s
Docker Build and Push / build (pull_request) Skipped
2026-10-09 12:21:45 +00:00
OpenCode Cabillot aa30111a8e ci: unique qdrant container name per run — parallel workflows on one host clash
PR Checks / hadolint (pull_request) Successful in 41s
Docker Build and Push / test (pull_request) Successful in 4m14s
Docker Build and Push / integration-test (pull_request) Failing after 4m23s
PR Checks / pytest (pull_request) Successful in 3m52s
PR Checks / build-test (pull_request) Successful in 5m26s
Docker Build and Push / build (pull_request) Skipped
Two concurrent jobs (docker-build + PR Checks on the same push) both
used container name 'qdrant'; the second job's 'docker rm -f qdrant'
destroyed the first job's Qdrant mid-run, so the test collection 404'd.
Name is now qdrant-${{ github.run_id }} everywhere (cleanup included).
2026-10-09 01:28:43 +00:00
OpenCode Cabillot 55841310bb ci: use pytest -m integration in docker-build.yaml (marker replaces --ignore file check)
PR Checks / hadolint (pull_request) Successful in 40s
PR Checks / build-test (pull_request) Successful in 1m3s
Docker Build and Push / integration-test (pull_request) Failing after 4m52s
PR Checks / pytest (pull_request) Successful in 3m55s
Docker Build and Push / test (pull_request) Successful in 4m31s
Docker Build and Push / build (pull_request) Skipped
2026-10-09 01:07:34 +00:00
OpenCode Cabillot 9417e577a9 test: mark integration suite with pytest.mark.integration
PR Checks / hadolint (pull_request) Successful in 32s
Docker Build and Push / test (pull_request) Successful in 3m40s
Docker Build and Push / integration-test (pull_request) Failing after 4m20s
PR Checks / pytest (pull_request) Successful in 3m29s
PR Checks / build-test (pull_request) Successful in 4m38s
Docker Build and Push / build (pull_request) Skipped
Required for -m 'not integration' to actually deselect them (pytest.ini
addopts) and for 'pytest -m integration' in the nightly CI job.
2026-10-09 00:20:35 +00:00
OpenCode Cabillot de27820cc7 ci: run pytest in all workflows — real unit job + Qdrant-backed integration job
- pr.yaml: new pytest job (unit tests only, integration deselected by marker)
- main.yaml / cron.yaml / tag.yaml: replace the build-only 'test' job with a
  real pytest unit job + an integration-test job that starts a Qdrant
  container sharing the job container's network namespace
  (--network container:$(hostname) — bypasses the Gitea runner bug with
  service containers), waits for /healthz, runs 'pytest -m integration',
  then tears down.
- build/build-push jobs now require [test, integration-test]; previously
  'test' was only a docker build and pytest never ran in CI.
- integration-test first cleans up any stale qdrant container (rerun-safe).
- setup-python SHA aligned with docker-build.yaml (v7, python 3.13/3.14).
2026-10-09 00:18:25 +00:00
jcabillot 388b345a2f Merge pull request 'ci: add Renovate workflow' (#44) from add-renovate-workflow into main
Main Release / test (push) Successful in 54s
Renovate / renovate (push) Successful in 1m55s
Main Release / build (push) Successful in 54s
Main Release / tag (push) Successful in 54s
Docker Build and Push / build (push) Successful in 6m17s
Tag Release / build-push (push) Successful in 4m50s
Main Release / hadolint (push) Successful in 40s
Docker Build and Push / test (push) Successful in 4m31s
Docker Build and Push / integration-test (push) Successful in 5m24s
Tag Release / hadolint (push) Successful in 42s
Tag Release / test (push) Successful in 56s
Reviewed-on: #44
2026-10-08 20:17:24 -04:00
OpenCode Cabillot b9e22766c0 test: cover build_filter (participant should-clause, date ranges, combination) 2026-10-09 00:16:58 +00:00
OpenCode Cabillot e32806aab9 test: add indexer unit tests + integration marker; make indexer importable without env
- pytest.ini: 'integration' marker, deselected by default; the integration
  suite requires a live Qdrant and will only run in the nightly CI job.
- src/indexer.py: move env validation from import time into _validate_config()
  called by main() — the module can now be imported without MAILDIR_PATH /
  QDRANT_URL / COLLECTION_NAME set, enabling unit tests.
- tests/test_indexer.py: 27 new unit tests, no network and no model:
  decode_mime_words (encoded-words utf-8/iso-8859-1, mixed), extract_text_from_html
  (tags, scripts), normalize_email_address, parse_email_message with real
  EmailMessage objects (plain bodies, attachments detected and excluded from
  body, HTML alternative, unknown charset fallback via raw bytes message,
  encoded attachment filename), get_recent_keys against a real tmp_path
  Maildir (fresh mtime selected, old mtime excluded, colon-suffix keys).
  mailbox.Maildir(create=True) does not create cur/new/tmp on Python 3.11 —
  the test helper pre-creates them.
2026-10-09 00:16:10 +00:00
opencodecabilloteu 6b17ca4ad5 Merge pull request 'chore(deps): update docker/setup-buildx-action digest to f87e599' (#51) from renovate/docker-setup-buildx-action-digest into main
Main Release / test (push) Successful in 2m2s
Main Release / build (push) Successful in 18s
Docker Build and Push / build (push) Successful in 2m11s
Main Release / tag (push) Successful in 11s
Tag Release / build-push (push) Successful in 1m52s
Main Release / hadolint (push) Successful in 11s
Docker Build and Push / test (push) Successful in 1m58s
Docker Build and Push / integration-test (push) Successful in 2m3s
Tag Release / hadolint (push) Successful in 6s
Tag Release / test (push) Successful in 15s
2026-09-18 04:32:13 -04:00
renovate d1c531d8f2 chore(deps): update docker/setup-buildx-action digest to f87e599
renovate/stability-days Updates have met minimum release age requirement
PR Checks / hadolint (pull_request) Successful in 11s
PR Checks / build-test (pull_request) Successful in 26s
Docker Build and Push / test (pull_request) Successful in 1m43s
Docker Build and Push / integration-test (pull_request) Successful in 1m55s
Docker Build and Push / build (pull_request) Successful in 1m35s
2026-09-18 08:10:55 +00:00
opencodecabilloteu 24b4e090a2 Merge pull request 'chore(deps): update docker/build-push-action digest to c3c9e26' (#49) from renovate/docker-build-push-action-digest into main
Docker Build and Push / integration-test (push) Successful in 2m26s
Main Release / test (push) Successful in 26s
Main Release / build (push) Successful in 22s
Docker Build and Push / build (push) Successful in 2m35s
Main Release / tag (push) Successful in 11s
Tag Release / build-push (push) Successful in 2m47s
Docker Build and Push / test (push) Successful in 2m7s
Main Release / hadolint (push) Successful in 10s
Tag Release / hadolint (push) Successful in 10s
Tag Release / test (push) Successful in 20s
2026-09-17 06:30:15 -04:00
renovate 1f66018225 chore(deps): update docker/build-push-action digest to c3c9e26
renovate/stability-days Updates have met minimum release age requirement
PR Checks / hadolint (pull_request) Successful in 12s
PR Checks / build-test (pull_request) Successful in 26s
Docker Build and Push / test (pull_request) Successful in 1m59s
Docker Build and Push / integration-test (pull_request) Successful in 2m8s
Docker Build and Push / build (pull_request) Successful in 1m47s
2026-09-17 10:09:45 +00:00
opencodecabilloteu 8ecbfbb613 Merge pull request 'chore(deps): update docker/build-push-action action to v7.4.0' (#50) from renovate/docker-build-push-action-7.x into main
Main Release / test (push) Successful in 2m13s
Main Release / build (push) Successful in 19s
Docker Build and Push / build (push) Successful in 2m26s
Main Release / tag (push) Successful in 12s
Tag Release / build-push (push) Successful in 2m57s
Docker Build and Push / test (push) Successful in 1m55s
Main Release / hadolint (push) Successful in 8s
Docker Build and Push / integration-test (push) Successful in 2m4s
Tag Release / hadolint (push) Successful in 9s
Tag Release / test (push) Successful in 20s
2026-09-17 05:32:23 -04:00
renovate 8a4fdb6d49 chore(deps): update docker/build-push-action action to v7.4.0
renovate/stability-days Updates have met minimum release age requirement
PR Checks / hadolint (pull_request) Successful in 10s
Docker Build and Push / test (pull_request) Successful in 2m6s
PR Checks / build-test (pull_request) Successful in 24s
Docker Build and Push / integration-test (pull_request) Successful in 2m21s
Docker Build and Push / build (pull_request) Successful in 1m58s
2026-09-17 09:12:14 +00:00
jcabillot 32cd00b8c6 Merge pull request 'chore: remove legacy GitLab CI and Jenkins files' (#47) from ci/remove-legacy-ci into main
Main Release / test (push) Successful in 3m27s
Main Release / build (push) Successful in 36s
Docker Build and Push / build (push) Successful in 9m35s
Main Release / tag (push) Successful in 20s
Tag Release / build-push (push) Successful in 6m46s
Docker Build and Push / integration-test (push) Successful in 3m56s
Main Release / hadolint (push) Successful in 21s
Docker Build and Push / test (push) Successful in 4m54s
Tag Release / hadolint (push) Successful in 12s
Tag Release / test (push) Successful in 29s
Reviewed-on: #47
2026-09-02 09:49:33 -04:00
OpenCode Cabillot c77d8b4fe6 chore: remove legacy GitLab CI and Jenkins files
PR Checks / hadolint (pull_request) Successful in 15s
Docker Build and Push / test (pull_request) Successful in 5m0s
PR Checks / build-test (pull_request) Successful in 3m59s
Docker Build and Push / integration-test (pull_request) Successful in 5m16s
Docker Build and Push / build (pull_request) Successful in 10m15s
2026-09-02 13:46:30 +00:00
opencodecabilloteu 6347ee811d Merge pull request 'chore(deps): update hadolint/hadolint-action action to v3.5.0' (#46) from renovate/hadolint-hadolint-action-3.x into main
Main Release / test (push) Successful in 2m52s
Main Release / build (push) Successful in 30s
Main Release / tag (push) Successful in 16s
Docker Build and Push / build (push) Successful in 3m31s
Tag Release / build-push (push) Successful in 3m38s
Docker Build and Push / test (push) Successful in 2m17s
Main Release / hadolint (push) Successful in 13s
Docker Build and Push / integration-test (push) Successful in 3m20s
Tag Release / hadolint (push) Successful in 14s
Tag Release / test (push) Successful in 28s
2026-08-26 12:01:57 -04:00
renovate 8480e4e322 chore(deps): update hadolint/hadolint-action action to v3.5.0
renovate/stability-days Updates have met minimum release age requirement
PR Checks / hadolint (pull_request) Successful in 16s
Docker Build and Push / test (pull_request) Successful in 2m40s
Docker Build and Push / integration-test (pull_request) Successful in 3m54s
PR Checks / build-test (pull_request) Successful in 3m26s
Docker Build and Push / build (pull_request) Successful in 3m27s
2026-08-26 15:15:16 +00:00
opencodecabilloteu 99a52ea9ae Merge pull request 'chore(deps): update docker/setup-buildx-action digest to 37fe631' (#45) from renovate/docker-setup-buildx-action-digest into main
Main Release / test (push) Successful in 44s
Main Release / build (push) Successful in 31s
Main Release / tag (push) Successful in 22s
Docker Build and Push / build (push) Successful in 4m7s
Tag Release / build-push (push) Successful in 3m47s
Docker Build and Push / integration-test (push) Successful in 1m13s
Docker Build and Push / test (push) Successful in 56s
Main Release / hadolint (push) Successful in 15s
Tag Release / test (push) Successful in 38s
Tag Release / hadolint (push) Successful in 19s
2026-08-21 06:39:42 -04:00
renovate b4de80928a chore(deps): update docker/setup-buildx-action digest to 37fe631
renovate/stability-days Updates have met minimum release age requirement
Docker Build and Push / test (pull_request) Successful in 51s
PR Checks / hadolint (pull_request) Successful in 20s
Docker Build and Push / integration-test (pull_request) Successful in 1m10s
PR Checks / build-test (pull_request) Successful in 37s
Docker Build and Push / build (pull_request) Successful in 2m44s
2026-08-21 10:19:07 +00:00
OpenCode Cabillot 478affe4fa ci: add Renovate workflow
PR Checks / hadolint (pull_request) Successful in 12s
PR Checks / build-test (pull_request) Successful in 2m4s
Docker Build and Push / integration-test (pull_request) Successful in 1m56s
Docker Build and Push / test (pull_request) Successful in 2m0s
Docker Build and Push / build (pull_request) In progress
2026-08-02 23:46:53 +00:00
19 changed files with 722 additions and 98 deletions

No files matched your search

+55 -6
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0 - uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
continue-on-error: true continue-on-error: true
with: with:
dockerfile: pkg/Dockerfile dockerfile: pkg/Dockerfile
@@ -18,17 +18,66 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
- run: docker build -f pkg/Dockerfile -t ci-image:${{ github.sha }} . with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt pytest
- name: Run unit tests
run: pytest tests/ -v
env:
# indexer/server read env at import; unit tests need no real backend
QDRANT_URL: http://localhost:6333
COLLECTION_NAME: test
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Cleanup old Qdrant container
run: |
docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true
- name: Start Qdrant
run: |
docker run -d --name qdrant-${{ github.run_id }} \
--network "container:$(hostname)" \
docker.io/qdrant/qdrant:latest
- name: Wait for Qdrant
run: |
for i in $(seq 1 30); do
curl -s http://localhost:6333/healthz \
&& echo "QDRANT ready" && break
echo "Waiting for Qdrant... ($i/30)"
sleep 1
done
curl -s http://localhost:6333/healthz > /dev/null
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt pytest
- name: Run integration tests
run: pytest tests/ -v -m integration
env:
QDRANT_URL: http://localhost:6333
COLLECTION_NAME: test_mcp_maildir
- name: Cleanup Qdrant
if: always()
run: docker rm -f qdrant-${{ github.run_id }} || true
build-push: build-push:
needs: [test] needs: [test, integration-test]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -43,7 +92,7 @@ jobs:
images: jcabillot/mcp-maildir images: jcabillot/mcp-maildir
tags: | tags: |
type=raw,value=${{ steps.get-latest-tag.outputs.tag }}-latest,enable=${{ steps.get-latest-tag.outputs.tag != '' }} type=raw,value=${{ steps.get-latest-tag.outputs.tag }}-latest,enable=${{ steps.get-latest-tag.outputs.tag != '' }}
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
context: . context: .
file: pkg/Dockerfile file: pkg/Dockerfile
+36 -10
View File
@@ -29,18 +29,35 @@ jobs:
- name: Start Qdrant - name: Start Qdrant
run: | run: |
docker rm -f qdrant 2>/dev/null || true docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true
docker run -d --name qdrant \ docker run -d --name qdrant-${{ github.run_id }} \
--network "container:$(hostname)" \ --network "container:$(hostname)" \
docker.io/qdrant/qdrant:latest docker.io/qdrant/qdrant:v1.19
- name: Wait for Qdrant - name: Wait for Qdrant
run: | run: |
for i in $(seq 1 30); do # /healthz answers before the storage engine accepts writes, and a cold
curl -s http://localhost:6333/healthz && echo "QDRANT ready" && break # container on a loaded host can take several seconds to create its
echo "Waiting for Qdrant... ($i/30)" # first collection. Gate on a real write round-trip, not on /healthz.
ready=""
for i in $(seq 1 60); do
if curl -sf "http://localhost:6333/healthz" > /dev/null 2>&1 \
&& curl -sf -X PUT "http://localhost:6333/collections/ci_readiness_probe?timeout=60" \
-H 'Content-Type: application/json' \
-d '{"vectors":{"size":4,"distance":"Cosine"}}' > /dev/null 2>&1; then
curl -sf -X DELETE "http://localhost:6333/collections/ci_readiness_probe" > /dev/null 2>&1 || true
ready="yes"
echo "QDRANT ready: write path OK (attempt $i)"
break
fi
echo "Waiting for Qdrant... ($i/60)"
sleep 1 sleep 1
done done
if [ -z "$ready" ]; then
echo "Qdrant write path never became ready"
docker logs --tail 100 qdrant-${{ github.run_id }} || true
exit 1
fi
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with: with:
@@ -49,14 +66,23 @@ jobs:
run: | run: |
pip install -r requirements.txt pytest pip install -r requirements.txt pytest
- name: Run integration tests - name: Run integration tests
run: pytest tests/test_integration.py -v run: pytest tests/ -v -m integration
env: env:
QDRANT_URL: http://localhost:6333 QDRANT_URL: http://localhost:6333
COLLECTION_NAME: test_mcp_maildir COLLECTION_NAME: test_mcp_maildir
# The qdrant client defaults to a 5s request timeout, which a cold
# collection on a shared runner can exceed; this job has no SLA.
QDRANT_TIMEOUT: "60"
- name: Qdrant diagnostics
if: failure()
run: |
docker ps -a || true
docker logs --tail 200 qdrant-${{ github.run_id }} || true
- name: Cleanup Qdrant - name: Cleanup Qdrant
if: always() if: always()
run: docker rm -f qdrant 2>/dev/null || true run: docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -65,7 +91,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to Docker Hub - name: Login to Docker Hub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@@ -83,7 +109,7 @@ jobs:
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with: with:
context: . context: .
file: pkg/Dockerfile file: pkg/Dockerfile
+81 -6
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0 - uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
continue-on-error: true continue-on-error: true
with: with:
dockerfile: pkg/Dockerfile dockerfile: pkg/Dockerfile
@@ -18,15 +18,90 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
- run: docker build -f pkg/Dockerfile -t ci-image:${{ github.sha }} . with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt pytest ruff==0.17.1
- name: Lint (undefined names)
run: ruff check src/ tests/ --select F821
- name: Run unit tests
run: pytest tests/ -v
env:
# indexer/server read env at import; unit tests need no real backend
QDRANT_URL: http://localhost:6333
COLLECTION_NAME: test
build: integration-test:
needs: [test]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Cleanup old Qdrant container
run: |
docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true
- name: Start Qdrant
run: |
docker run -d --name qdrant-${{ github.run_id }} \
--network "container:$(hostname)" \
docker.io/qdrant/qdrant:v1.19
- name: Wait for Qdrant
run: |
# /healthz answers before the storage engine accepts writes, and a cold
# container on a loaded host can take several seconds to create its
# first collection. Gate on a real write round-trip, not on /healthz.
ready=""
for i in $(seq 1 60); do
if curl -sf "http://localhost:6333/healthz" > /dev/null 2>&1 \
&& curl -sf -X PUT "http://localhost:6333/collections/ci_readiness_probe?timeout=60" \
-H 'Content-Type: application/json' \
-d '{"vectors":{"size":4,"distance":"Cosine"}}' > /dev/null 2>&1; then
curl -sf -X DELETE "http://localhost:6333/collections/ci_readiness_probe" > /dev/null 2>&1 || true
ready="yes"
echo "QDRANT ready: write path OK (attempt $i)"
break
fi
echo "Waiting for Qdrant... ($i/60)"
sleep 1
done
if [ -z "$ready" ]; then
echo "Qdrant write path never became ready"
docker logs --tail 100 qdrant-${{ github.run_id }} || true
exit 1
fi
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt pytest
- name: Run integration tests
run: pytest tests/ -v -m integration
env:
QDRANT_URL: http://localhost:6333
COLLECTION_NAME: test_mcp_maildir
# The qdrant client defaults to a 5s request timeout, which a cold
# collection on a shared runner can exceed; this job has no SLA.
QDRANT_TIMEOUT: "60"
- name: Qdrant diagnostics
if: failure()
run: |
docker ps -a || true
docker logs --tail 200 qdrant-${{ github.run_id }} || true
- name: Cleanup Qdrant
if: always()
run: docker rm -f qdrant-${{ github.run_id }} || true
build:
needs: [test, integration-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- run: docker build -f pkg/Dockerfile -t jcabillot/mcp-maildir:${{ github.sha }} . - run: docker build -f pkg/Dockerfile -t jcabillot/mcp-maildir:${{ github.sha }} .
tag: tag:
+18 -2
View File
@@ -9,14 +9,30 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0 - uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
continue-on-error: true continue-on-error: true
with: with:
dockerfile: pkg/Dockerfile dockerfile: pkg/Dockerfile
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt pytest
- name: Run unit tests
run: pytest tests/ -v
env:
# indexer/server read env at import; unit tests need no real backend
QDRANT_URL: http://localhost:6333
COLLECTION_NAME: test
build-test: build-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- run: docker build -f pkg/Dockerfile -t ci-image:${{ github.sha }} . - run: docker build -f pkg/Dockerfile -t ci-image:${{ github.sha }} .
+12
View File
@@ -0,0 +1,12 @@
name: Renovate
on:
push:
branches:
- main
workflow_dispatch:
jobs:
renovate:
uses: perso/gitea-pipelines/.gitea/workflows/renovate.yaml@main
secrets: inherit
+75 -7
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0 - uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
continue-on-error: true continue-on-error: true
with: with:
dockerfile: pkg/Dockerfile dockerfile: pkg/Dockerfile
@@ -18,15 +18,64 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
- run: docker build -f pkg/Dockerfile -t ci-image:${{ github.sha }} . with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt pytest
- name: Run unit tests
run: pytest tests/ -v
env:
# indexer/server read env at import; unit tests need no real backend
QDRANT_URL: http://localhost:6333
COLLECTION_NAME: test
build-push: integration-test:
needs: [test]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Cleanup old Qdrant container
run: |
docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true
- name: Start Qdrant
run: |
docker run -d --name qdrant-${{ github.run_id }} \
--network "container:$(hostname)" \
docker.io/qdrant/qdrant:latest
- name: Wait for Qdrant
run: |
for i in $(seq 1 30); do
curl -s http://localhost:6333/healthz \
&& echo "QDRANT ready" && break
echo "Waiting for Qdrant... ($i/30)"
sleep 1
done
curl -s http://localhost:6333/healthz > /dev/null
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
- name: Install dependencies
run: pip install -r requirements.txt pytest
- name: Run integration tests
run: pytest tests/ -v -m integration
env:
QDRANT_URL: http://localhost:6333
COLLECTION_NAME: test_mcp_maildir
- name: Cleanup Qdrant
if: always()
run: docker rm -f qdrant-${{ github.run_id }} || true
build-push:
needs: [test, integration-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -38,7 +87,7 @@ jobs:
tags: | tags: |
type=ref,event=tag type=ref,event=tag
type=ref,event=tag,suffix=-latest type=ref,event=tag,suffix=-latest
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with: with:
context: . context: .
file: pkg/Dockerfile file: pkg/Dockerfile
@@ -46,3 +95,22 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
pull: true pull: true
trigger-renovate:
# The deployment repo perso_private/tf-depl-kubernetes pins this image; its
# Renovate instance only scans hourly, so this dispatch opens the image bump
# PR within minutes of the release instead of up to an hour later.
needs: [build-push]
runs-on: ubuntu-latest
steps:
- name: Trigger Renovate on perso_private/tf-depl-kubernetes
# needs: build-push — Renovate reads Docker Hub, so a scan started before
# the push lands would only see the previous release.
# RENOVATE_TOKEN is the org-level secret of this organisation.
run: |
curl -sS -f -X POST \
-u "renovate:${{ secrets.RENOVATE_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{{"ref":"main"}}' \
"https://scm.cabillot.eu/api/v1/repos/perso_private/tf-depl-kubernetes/actions/workflows/renovate.yaml/dispatches"
echo "Renovate dispatched on perso_private/tf-depl-kubernetes"
Vendored
-38
View File
@@ -1,38 +0,0 @@
pipeline {
environment {
registry = 'https://registry.hub.docker.com'
registryCredential = 'dockerhub_jcabillot'
dockerImage = 'jcabillot/mcp-maildir'
}
agent any
triggers {
cron('@midnight')
}
stages {
stage('Clone repository') {
steps{
checkout scm
}
}
stage('Build image') {
steps{
sh 'docker build --force-rm=true --no-cache=true --pull -t ${dockerImage} -f pkg/Dockerfile .'
}
}
stage('Deploy Image') {
steps{
script {
withCredentials([usernamePassword(credentialsId: 'dockerhub_jcabillot', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
sh 'docker login --username ${DOCKER_USER} --password ${DOCKER_PASS}'
sh 'docker push ${dockerImage}'
}
}
}
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
qdrant: qdrant:
image: docker.io/qdrant/qdrant:latest image: docker.io/qdrant/qdrant:v1.19
container_name: mcp_maildir_qdrant container_name: mcp_maildir_qdrant
ports: ports:
- "127.0.0.1:6333:6333" # REST API - "127.0.0.1:6333:6333" # REST API
@@ -15,7 +15,7 @@ services:
dockerfile: pkg/Dockerfile dockerfile: pkg/Dockerfile
container_name: mcp_maildir_server container_name: mcp_maildir_server
ports: ports:
- "127.0.0.1:8000:8000" # Expose the MCP HTTP (SSE) server - "127.0.0.1:8000:8000" # Expose the MCP Streamable HTTP server (/mcp)
env_file: env_file:
- .env - .env
environment: environment:
+1 -1
View File
@@ -1,5 +1,5 @@
# 3.13, because with 3.14 it requires gcc (wheels not available) # 3.13, because with 3.14 it requires gcc (wheels not available)
FROM docker.io/library/python:3.14-slim FROM docker.io/library/python:3.15-slim
# Set environment variables # Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
+5
View File
@@ -1,3 +1,8 @@
[pytest] [pytest]
testpaths = tests testpaths = tests
pythonpath = src pythonpath = src
; Integration tests (tests/test_integration.py) require a live Qdrant on
; localhost:6333 — they only run in the nightly CI job (or manually with -m integration).
markers =
integration: requires a live Qdrant instance (deselected by default)
addopts = -m "not integration" -p no:cacheprovider
+6 -3
View File
@@ -3,9 +3,12 @@
"customManagers": [ "customManagers": [
{ {
"customType": "regex", "customType": "regex",
"description": "Track qdrant/qdrant Docker image from docker-compose.yml (native manager missed it)", "description": "Track the pinned qdrant/qdrant image in docker-compose.yml and the CI workflows (native manager missed it)",
"managerFilePatterns": ["/^docker-compose\\.ya?ml$/"], "managerFilePatterns": [
"matchStrings": ["image:\\s*(?:docker\\.io/)?qdrant/qdrant:(?<currentValue>[^\\s]+)"], "/^docker-compose\\.ya?ml$/",
"/^\\.gitea/workflows/.*\\.ya?ml$/"
],
"matchStrings": ["(?:docker\\.io/)?qdrant/qdrant:(?<currentValue>[^\\s\"']+)"],
"depNameTemplate": "qdrant/qdrant", "depNameTemplate": "qdrant/qdrant",
"datasourceTemplate": "docker" "datasourceTemplate": "docker"
} }
+1 -1
View File
@@ -1,4 +1,4 @@
fastmcp>=3,<4 fastmcp>=4,<5
qdrant-client>=1,<2 qdrant-client>=1,<2
fastembed>=0.8.0,<1 fastembed>=0.8.0,<1
python-dotenv>=1,<2 python-dotenv>=1,<2
+18 -8
View File
@@ -22,12 +22,27 @@ from bs4 import BeautifulSoup
# Load .env config # Load .env config
load_dotenv() load_dotenv()
# Configuration # Configuration (validated lazily in main() so the module can be imported
# and unit-tested without environment variables)
MAILDIR_PATH = os.environ.get("MAILDIR_PATH", "") MAILDIR_PATH = os.environ.get("MAILDIR_PATH", "")
MAILDIR_FOLDERS = os.environ.get("MAILDIR_FOLDERS", "") MAILDIR_FOLDERS = os.environ.get("MAILDIR_FOLDERS", "")
QDRANT_URL = os.environ.get("QDRANT_URL", "") QDRANT_URL = os.environ.get("QDRANT_URL", "")
COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "") COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "")
# Embedding model and batching. EMBEDDING_MODEL_NAME MUST stay in sync with the
# one used by server.py: indexer and server have to embed with the same model,
# otherwise the vectors stored in Qdrant are not comparable with query vectors.
EMBEDDING_MODEL_NAME = os.environ.get("EMBEDDING_MODEL_NAME", "BAAI/bge-small-en-v1.5")
BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "100"))
EMBEDDING_BATCH_SIZE = int(os.environ.get("EMBEDDING_BATCH_SIZE", "64"))
METADATA_COLLECTION = "mcp_indexer_metadata"
INCREMENTAL_DAYS = int(os.environ.get("INCREMENTAL_DAYS", "7"))
FORCE_REINDEX = os.environ.get("FORCE_REINDEX", "").lower() in ("1", "true", "yes")
def _validate_config():
"""Raises if required environment variables are missing. Called from main()."""
if not MAILDIR_PATH: if not MAILDIR_PATH:
raise ValueError("MAILDIR_PATH environment variable is required.") raise ValueError("MAILDIR_PATH environment variable is required.")
if not QDRANT_URL: if not QDRANT_URL:
@@ -35,13 +50,6 @@ if not QDRANT_URL:
if not COLLECTION_NAME: if not COLLECTION_NAME:
raise ValueError("COLLECTION_NAME environment variable is required.") raise ValueError("COLLECTION_NAME environment variable is required.")
EMBEDDING_MODEL_NAME = os.environ.get("EMBEDDING_MODEL_NAME", "BAAI/bge-small-en-v1.5")
BATCH_SIZE = int(os.environ.get("BATCH_SIZE", "100"))
EMBEDDING_BATCH_SIZE = int(os.environ.get("EMBEDDING_BATCH_SIZE", "64"))
METADATA_COLLECTION = "mcp_indexer_metadata"
INCREMENTAL_DAYS = int(os.environ.get("INCREMENTAL_DAYS", "7"))
FORCE_REINDEX = os.environ.get("FORCE_REINDEX", "").lower() in ("1", "true", "yes")
def decode_mime_words(s: str) -> str: def decode_mime_words(s: str) -> str:
"""Decodes MIME encoded strings (e.g. subjects, filenames).""" """Decodes MIME encoded strings (e.g. subjects, filenames)."""
if not s: if not s:
@@ -251,6 +259,8 @@ def main():
Main ingestion function. Main ingestion function.
Reads Maildir, extracts text, generates local embeddings, and pushes to Qdrant. Reads Maildir, extracts text, generates local embeddings, and pushes to Qdrant.
""" """
_validate_config()
print(f"Indexing emails from {MAILDIR_PATH} into {QDRANT_URL}...") print(f"Indexing emails from {MAILDIR_PATH} into {QDRANT_URL}...")
if not os.path.exists(MAILDIR_PATH): if not os.path.exists(MAILDIR_PATH):
+26 -5
View File
@@ -9,6 +9,7 @@ from typing import Any, Optional
import qdrant_client as qdrant_pkg import qdrant_client as qdrant_pkg
from dateutil import parser as date_parser from dateutil import parser as date_parser
from email.utils import parseaddr from email.utils import parseaddr
import uvicorn
from dotenv import load_dotenv from dotenv import load_dotenv
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
@@ -36,6 +37,10 @@ COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "")
EMBEDDING_MODEL_NAME = os.environ.get("EMBEDDING_MODEL_NAME", "BAAI/bge-small-en-v1.5") EMBEDDING_MODEL_NAME = os.environ.get("EMBEDDING_MODEL_NAME", "BAAI/bge-small-en-v1.5")
DEFAULT_LIMIT = int(os.environ.get("SEARCH_LIMIT", "50")) DEFAULT_LIMIT = int(os.environ.get("SEARCH_LIMIT", "50"))
# Per-request timeout (seconds) for Qdrant. The client default is 5s, which is
# tight for a cold collection or a busy host: unset means keep that default.
QDRANT_TIMEOUT = int(os.environ["QDRANT_TIMEOUT"]) if os.environ.get("QDRANT_TIMEOUT") else None
if not QDRANT_URL: if not QDRANT_URL:
raise ValueError("QDRANT_URL environment variable is required.") raise ValueError("QDRANT_URL environment variable is required.")
if not COLLECTION_NAME: if not COLLECTION_NAME:
@@ -60,8 +65,8 @@ def get_qdrant_client() -> QdrantClient:
"""Returns a singleton Qdrant client.""" """Returns a singleton Qdrant client."""
global _qdrant_client global _qdrant_client
if _qdrant_client is None: if _qdrant_client is None:
logger.info(f"Connecting to Qdrant at {QDRANT_URL}") logger.info(f"Connecting to Qdrant at {QDRANT_URL} (timeout={QDRANT_TIMEOUT})")
_qdrant_client = QdrantClient(url=QDRANT_URL) _qdrant_client = QdrantClient(url=QDRANT_URL, timeout=QDRANT_TIMEOUT)
return _qdrant_client return _qdrant_client
@@ -340,6 +345,23 @@ def read_email(message_id: str):
return {"error": f"read_email failed: {exc}"} return {"error": f"read_email failed: {exc}"}
# Streamable HTTP is the transport supported by current MCP clients (OpenCode,
# Claude, ...). The legacy SSE transport is deprecated and is also a POST
# target for clients that only speak Streamable HTTP, which answers 405.
MCP_HOST = "0.0.0.0"
MCP_PORT = 8000
MCP_HTTP_PATH = "/mcp"
def build_app():
"""Builds the ASGI app serving the MCP endpoint over Streamable HTTP.
The MCP endpoint lives at MCP_HTTP_PATH; the custom route declared above
keeps serving GET /health for the Kubernetes probes.
"""
return mcp.http_app(path=MCP_HTTP_PATH)
if __name__ == "__main__": if __name__ == "__main__":
logger.info("Initializing models before starting server...") logger.info("Initializing models before starting server...")
try: try:
@@ -348,6 +370,5 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
logger.error(f"Failed to load models: {e}") logger.error(f"Failed to load models: {e}")
logger.info("Starting SSE server on 0.0.0.0:8000...") logger.info(f"Starting Streamable HTTP server on {MCP_HOST}:{MCP_PORT}{MCP_HTTP_PATH}...")
# Start the MCP server using SSE (Server-Sent Events) over HTTP uvicorn.run(build_app(), host=MCP_HOST, port=MCP_PORT)
mcp.run(transport="sse", host="0.0.0.0", port=8000)
+15
View File
@@ -0,0 +1,15 @@
"""Shared test configuration — sets env vars ONCE before any test module runs.
The env must be consistent for the whole pytest session: server.py freezes
COLLECTION_NAME/QDRANT_URL at import time, while the integration fixture
reads os.environ at fixture time. If any test module mutates os.environ after
that (test_server.py used to set COLLECTION_NAME='test'), the fixture creates
a different collection than the one server functions query → 404.
Keep all env wiring here so import order no longer matters.
"""
import os
os.environ.setdefault("QDRANT_URL", "http://localhost:6333")
os.environ.setdefault("COLLECTION_NAME", "test_mcp_maildir")
os.environ.setdefault("EMBEDDING_MODEL_NAME", "BAAI/bge-small-en-v1.5")
+254
View File
@@ -0,0 +1,254 @@
"""Unit tests for mcp-maildir indexer parsing functions.
Pure-function tests: no Qdrant, no embedding model, no network.
A throwaway Maildir is built in tmp_path for get_recent_keys().
"""
import mailbox
import os
import time
import uuid
from datetime import datetime, timezone, timedelta
from email.message import EmailMessage
from email.utils import format_datetime
import pytest
from indexer import (
decode_mime_words,
extract_text_from_html,
normalize_email_address,
parse_email_message,
get_recent_keys,
METADATA_COLLECTION,
EMBEDDING_MODEL_NAME,
BATCH_SIZE,
EMBEDDING_BATCH_SIZE,
)
# ---------------------------------------------------------------------------
# decode_mime_words
# ---------------------------------------------------------------------------
class TestDecodeMimeWords:
def test_empty_and_none(self):
assert decode_mime_words("") == ""
assert decode_mime_words(None) == ""
def test_plain_ascii(self):
assert decode_mime_words("Hello World") == "Hello World"
def test_encoded_word_utf8(self):
# =?utf-8?q?...?= encoded word
assert decode_mime_words("=?utf-8?q?Caf=C3=A9_au_lait?=") == "Café au lait"
def test_encoded_word_iso8859(self):
# decode_mime_words does not strip whitespace around the words
assert decode_mime_words("=?iso-8859-1?q?caf=E9?=") == "café"
def test_mixed_encoded_and_plain(self):
assert decode_mime_words("Re: =?utf-8?q?planning_2026?=") == "Re: planning 2026"
# ---------------------------------------------------------------------------
# extract_text_from_html
# ---------------------------------------------------------------------------
class TestExtractTextFromHtml:
def test_simple_paragraphs(self):
html = "<html><body><p>Hello</p><p>World</p></body></html>"
assert extract_text_from_html(html) == "Hello World"
def test_strips_tags_and_scripts(self):
html = "<div>Sign<span>ature</span><script>evil()</script>End</div>"
text = extract_text_from_html(html)
assert "evil()" not in text
# get_text uses separator=" ", so tags become whitespace
assert "Sign" in text and "ature" in text and "End" in text
def test_fallback_on_invalid_content(self):
# Garbage that would make the parser blow up should not raise
assert extract_text_from_html(None) is None
# ---------------------------------------------------------------------------
# parse_email_message — real mailbox.Message objects (no mocks)
# ---------------------------------------------------------------------------
def build_email(
subject="Hello",
body="This is the body.",
html=None,
attachments=None,
charset="utf-8",
):
"""Builds a real EmailMessage, optionally multipart with attachments."""
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = "Alice <alice@example.com>"
msg["To"] = "Bob <bob@example.com>"
msg["Message-ID"] = "<build-email@example.com>"
msg["Date"] = format_datetime(datetime(2026, 1, 15, 10, 0, 0, tzinfo=timezone.utc))
if html:
msg.set_content(body)
msg.add_alternative(html, subtype="html")
else:
msg.set_content(body, charset=charset)
for filename, content in (attachments or {}).items():
payload = content if isinstance(content, bytes) else content.encode()
msg.add_attachment(payload, maintype="application",
subtype="octet-stream", filename=filename)
return msg
def build_raw_email(raw_bytes: bytes):
"""Parses raw email bytes (for exotic headers without EmailMessage helpers)."""
import email as email_mod
return email_mod.message_from_bytes(raw_bytes)
class TestParseEmailMessage:
def test_plain_text_body(self):
msg = build_email(body="Line one.\nLine two.")
body, attachments = parse_email_message(msg)
assert "Line one." in body and "Line two." in body
assert attachments == []
def test_attachments_detected_and_excluded_from_body(self):
msg = build_email(
body="See attached.",
attachments={"report.pdf": b"%PDF-fake", "notes.txt": b"notes"},
)
body, attachments = parse_email_message(msg)
assert attachments == ["report.pdf", "notes.txt"]
assert "%PDF" not in body
assert "See attached." in body
def test_html_alternative_extracts_text(self):
msg = build_email(
body="Fallback plain text.",
html="<html><body><b>Bold</b> intro</body></html>",
)
body, _ = parse_email_message(msg)
assert "Bold" in body and "intro" in body
def test_unknown_charset_falls_back_not_crash(self):
# A payload claiming an unknown charset must not raise.
# Built from raw bytes: EmailMessage.set_content(charset=...) would
# reject the unknown encoding at build time, real mail does not.
raw = (
b"Subject: broken charset\n"
b"From: alice@example.com\n"
b"To: bob@example.com\n"
b"Content-Type: text/plain; charset=x-unknown-charset\n"
b"Content-Transfer-Encoding: 8bit\n\n"
b"body with broken charset\n"
)
msg = build_raw_email(raw)
body, attachments = parse_email_message(msg)
# Whatever the fallback, we must get a string body and no exception
assert isinstance(body, str)
assert attachments == []
def test_encoded_attachment_filename_decoded(self):
msg = build_email(
body="body",
attachments={},
)
# Add attachment with non-ascii filename via encoded word
msg.add_attachment(b"data", maintype="application", subtype="pdf",
filename="café-rapport.pdf")
body, attachments = parse_email_message(msg)
assert attachments == ["café-rapport.pdf"]
# ---------------------------------------------------------------------------
# normalize_email_address (indexer copy — same contract as server.py)
# ---------------------------------------------------------------------------
class TestIndexerNormalizeEmailAddress:
def test_display_name(self):
assert normalize_email_address("John Doe <john@example.com>") == "john@example.com"
def test_case_and_whitespace(self):
assert normalize_email_address(" USER@Example.COM ") == "user@example.com"
def test_empty(self):
assert normalize_email_address("") == ""
assert normalize_email_address(None) == ""
# ---------------------------------------------------------------------------
# get_recent_keys — real Maildir on disk (tmp_path), mtime-driven
# ---------------------------------------------------------------------------
def make_maildir(root):
"""Opens a Maildir at root — pre-creates cur/new/tmp because mailbox.Maildir
(Python 3.11) does not create them when adding a message."""
for sub in ("cur", "new", "tmp"):
os.makedirs(os.path.join(str(root), sub), exist_ok=True)
return mailbox.Maildir(str(root), create=False)
def write_maildir_message(maildir: mailbox.Maildir, msg: EmailMessage):
"""Adds a message and returns its key."""
return maildir.add(msg)
class TestGetRecentKeys:
def test_recent_file_selected(self, tmp_path):
md = make_maildir(tmp_path)
msg = build_email()
md.add(msg)
md.flush()
# mtime is now → within the window
keys = get_recent_keys(md, days=7)
assert len(keys) == 1
def test_old_file_excluded(self, tmp_path):
md = make_maildir(tmp_path)
md.add(build_email())
md.flush()
# Age every file beyond the cutoff
old = time.time() - 30 * 86400
for path in tmp_path.rglob("*.??*"):
if path.is_file():
os.utime(path, (old, old))
keys = get_recent_keys(md, days=7)
assert len(keys) == 0
def test_key_is_suffixless_filename(self, tmp_path):
"""Maildir keys strip the ':2,S' info suffix — get_recent_keys must match."""
md = make_maildir(tmp_path)
key = md.add(build_email())
md.flush()
keys = get_recent_keys(md, days=7)
assert keys # non-empty set of keys for fresh files
assert key.split(":")[0] in keys
for k in keys:
assert ":" not in k
# ---------------------------------------------------------------------------
# Regression: module-level constants consumed by main()
# ---------------------------------------------------------------------------
def test_indexer_module_constants_are_defined():
"""Regression (e32806a): the "make indexer importable without env" refactor
dropped these three module-level definitions while main() kept using them,
so every indexer run died with NameError before touching Qdrant."""
assert isinstance(EMBEDDING_MODEL_NAME, str) and EMBEDDING_MODEL_NAME
assert isinstance(BATCH_SIZE, int) and BATCH_SIZE > 0
assert isinstance(EMBEDDING_BATCH_SIZE, int) and EMBEDDING_BATCH_SIZE > 0
def test_embedding_model_is_the_same_as_the_server_one():
"""Indexer and server must embed with the same model, otherwise the vectors
stored by the indexer are not comparable with the query vectors."""
from server import EMBEDDING_MODEL_NAME as server_embedding_model
assert EMBEDDING_MODEL_NAME == server_embedding_model
+33 -5
View File
@@ -1,16 +1,21 @@
"""Integration tests for mcp-maildir with a real Qdrant instance.""" """Integration tests for mcp-maildir with a real Qdrant instance.
Env vars (QDRANT_URL, COLLECTION_NAME, EMBEDDING_MODEL_NAME) are set in
tests/conftest.py — don't set them here, they must match server.py's
import-time snapshot for every module in the session.
"""
import os import os
import uuid import uuid
import pytest import pytest
os.environ["QDRANT_URL"] = "http://localhost:6333"
os.environ["COLLECTION_NAME"] = "test_mcp_maildir"
os.environ["EMBEDDING_MODEL_NAME"] = "BAAI/bge-small-en-v1.5"
from server import get_qdrant_client, get_embedding_model, search_emails, read_email from server import get_qdrant_client, get_embedding_model, search_emails, read_email
from qdrant_client.http import models from qdrant_client.http import models
# Requires a live Qdrant on localhost:6333 (see pytest.ini: deselected by
# default; run with `pytest -m integration`, nightly CI does exactly that).
pytestmark = pytest.mark.integration
TEST_EMAILS = [ TEST_EMAILS = [
{ {
@@ -56,6 +61,21 @@ def qdrant_setup():
model = get_embedding_model() model = get_embedding_model()
collection_name = os.environ["COLLECTION_NAME"] collection_name = os.environ["COLLECTION_NAME"]
# Debug instrumentalisation — truth about which server we are talking to
# and what it holds (CI 404 diagnosis).
import logging
dbg = logging.getLogger("integration.debug")
try:
dbg.warning("qdrant client url: %s", client.http._client.base_url)
except Exception:
dbg.warning("qdrant client url: <introspection failed>")
server_info = None
try:
server_info = client.get_collections()
dbg.warning("collections BEFORE: %s", [c.name for c in server_info.collections])
except Exception as e:
dbg.warning("get_collections FAILED: %s", e)
# Clean up from previous runs # Clean up from previous runs
try: try:
client.delete_collection(collection_name) client.delete_collection(collection_name)
@@ -70,6 +90,14 @@ def qdrant_setup():
size=len(probe), distance=models.Distance.COSINE size=len(probe), distance=models.Distance.COSINE
), ),
) )
# Assert the collection is actually visible right after creation
names_after = [c.name for c in client.get_collections().collections]
dbg.warning("collections AFTER create: %s", names_after)
if collection_name not in names_after:
raise RuntimeError(
f"Fixture created collection {collection_name} but it is not in "
f"get_collections(): {names_after}"
)
# Index test emails # Index test emails
for email in TEST_EMAILS: for email in TEST_EMAILS:
+57 -4
View File
@@ -1,13 +1,14 @@
"""Unit tests for mcp-maildir server pure functions.""" """Unit tests for mcp-maildir server pure functions.
import os Env vars are set in tests/conftest.py (shared session-wide) — see the
os.environ["QDRANT_URL"] = "http://localhost:6333" conftest docstring for why test modules must not touch os.environ.
os.environ["COLLECTION_NAME"] = "test" """
from server import ( from server import (
normalize_email_address, normalize_email_address,
payload_matches_participant, payload_matches_participant,
format_search_result, format_search_result,
build_filter,
) )
@@ -108,3 +109,55 @@ class TestFormatSearchResult:
assert result["receiver"] is None assert result["receiver"] is None
assert result["subject"] is None assert result["subject"] is None
assert result["attachments"] == [] assert result["attachments"] == []
class TestBuildFilter:
def test_no_filters_returns_none(self):
assert build_filter(None, None, None) is None
def test_empty_strings_return_none(self):
assert build_filter("", "", "") is None
def test_participant_filter_is_should_clause_on_sender_and_receiver(self):
f = build_filter("alice@example.com", None, None)
assert f is not None
# The date conditions list must be absent and only the participant filter present
assert len(f.must) == 1
participant = f.must[0]
keys = [c.key for c in participant.should]
assert keys == ["sender", "receiver"]
assert all(c.match.value == "alice@example.com" for c in participant.should)
def test_participant_normalized_before_match(self):
f = build_filter("Alice <Alice@Example.COM>", None, None)
should = f.must[0].should
assert all(c.match.value == "alice@example.com" for c in should)
def test_date_range_only(self):
f = build_filter(None, "2026-01-01", "2026-06-30")
assert len(f.must) == 1
cond = f.must[0]
assert cond.key == "date"
assert cond.range.gte is not None
assert cond.range.lte is not None
def test_start_date_only(self):
f = build_filter(None, "2026-03-15", None)
cond = f.must[0]
assert cond.range.gte is not None
assert cond.range.lte is None
def test_end_date_only(self):
f = build_filter(None, None, "2026-03-15")
cond = f.must[0]
assert cond.range.gte is None
assert cond.range.lte is not None
def test_combined_participant_and_date(self):
f = build_filter("bob@example.org", "2026-01-01", "2026-12-31")
assert len(f.must) == 2
# One is the participant should-filter, the other the date range
kinds = sorted(
"participant" if getattr(c, "should", None) else "date" for c in f.must
)
assert kinds == ["date", "participant"]
+27
View File
@@ -0,0 +1,27 @@
"""Transport-level tests for the mcp-maildir ASGI app.
Current MCP clients (OpenCode, Claude, ...) only speak Streamable HTTP: they
POST JSON-RPC to the configured URL. Serving the legacy SSE transport alone
makes those clients fail with `405 Method Not Allowed`, so the app built by
`server.build_app()` must expose the MCP endpoint at /mcp.
Env vars are set in tests/conftest.py (shared session-wide).
"""
from server import build_app, MCP_HTTP_PATH
def app_paths() -> set:
return {getattr(route, "path", None) for route in build_app().routes}
class TestTransport:
def test_mcp_endpoint_is_streamable_http(self):
assert MCP_HTTP_PATH == "/mcp"
assert MCP_HTTP_PATH in app_paths()
def test_health_endpoint_is_exposed_for_probes(self):
assert "/health" in app_paths()
def test_legacy_sse_endpoint_is_gone(self):
assert "/sse" not in app_paths()