Compare commits

...
Author SHA1 Message Date
renovate 9495cd56bc chore(deps): update dependency python to 3.15
renovate/stability-days Updates have met minimum release age requirement
PR Checks / hadolint (pull_request) Successful in 56s
Docker Build and Push / test (pull_request) Failing after 2m25s
Docker Build and Push / integration-test (pull_request) Failing after 2m46s
Docker Build and Push / build (pull_request) Skipped
PR Checks / build-test (pull_request) Successful in 37s
PR Checks / pytest (pull_request) Failing after 1m30s
2026-10-18 20:08:24 +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 / hadolint (push) Successful in 37s
Docker Build and Push / test (push) Successful in 4m47s
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 / hadolint (push) Successful in 37s
Tag Release / test (push) Successful in 3m21s
Tag Release / integration-test (push) Successful in 4m25s
Tag Release / build-push (push) Successful in 4m11s
Tag Release / trigger-renovate (push) Failing after 20s
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 / hadolint (push) Successful in 17s
Docker Build and Push / test (push) Successful in 3m0s
Main Release / build (push) Successful in 21s
Renovate / renovate (push) Successful in 59s
Main Release / tag (push) Successful in 17s
Tag Release / hadolint (push) Successful in 24s
Docker Build and Push / build (push) Successful in 4m25s
Tag Release / test (push) Successful in 2m42s
Tag Release / integration-test (push) Successful in 2m35s
Tag Release / build-push (push) Successful in 3m4s
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 / hadolint (push) Canceled after 1m12s
Main Release / test (push) Canceled after 1m10s
Docker Build and Push / test (push) Successful in 2m14s
Renovate / renovate (push) Successful in 1m32s
Docker Build and Push / integration-test (push) Failing after 11m3s
Docker Build and Push / build (push) Skipped
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 / integration-test (push) Failing after 5m58s
Main Release / hadolint (push) Successful in 50s
Docker Build and Push / test (push) Successful in 5m1s
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 / hadolint (push) Successful in 19s
Tag Release / test (push) Successful in 3m17s
Tag Release / integration-test (push) Failing after 3m3s
Tag Release / build-push (push) Skipped
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
Docker Build and Push / build (pull_request) Successful in 2m39s
PR Checks / build-test (pull_request) Successful in 3m5s
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
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
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 / test (push) Successful in 2m54s
Tag Release / integration-test (push) Successful in 3m9s
Tag Release / build-push (push) Successful in 3m43s
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
Main Release / hadolint (push) Successful in 20s
Docker Build and Push / test (push) Successful in 2m25s
Renovate / renovate (push) Successful in 53s
Docker Build and Push / integration-test (push) Successful in 2m23s
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 / hadolint (push) Successful in 14s
Tag Release / test (push) Successful in 1m40s
Tag Release / integration-test (push) Successful in 1m45s
Tag Release / build-push (push) Successful in 2m46s
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 / hadolint (push) Successful in 2m18s
Docker Build and Push / test (push) Successful in 4m50s
Main Release / test (push) Successful in 4m38s
Docker Build and Push / integration-test (push) Successful in 5m19s
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 / hadolint (push) Successful in 30s
Tag Release / test (push) Successful in 2m25s
Tag Release / integration-test (push) Successful in 2m38s
Tag Release / build-push (push) Successful in 3m9s
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
Docker Build and Push / build (pull_request) Skipped
PR Checks / pytest (pull_request) Successful in 1m50s
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
Docker Build and Push / build (pull_request) Skipped
PR Checks / pytest (pull_request) Successful in 1m48s
PR Checks / build-test (pull_request) Successful in 2m24s
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
Docker Build and Push / build (pull_request) Skipped
PR Checks / pytest (pull_request) Successful in 2m0s
PR Checks / build-test (pull_request) Successful in 2m33s
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
Docker Build and Push / build (pull_request) Skipped
PR Checks / pytest (pull_request) Successful in 3m52s
PR Checks / build-test (pull_request) Successful in 5m26s
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
Docker Build and Push / build (pull_request) Skipped
PR Checks / pytest (pull_request) Successful in 3m29s
PR Checks / build-test (pull_request) Successful in 4m38s
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
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
16 changed files with 697 additions and 47 deletions

No files matched your search

+52 -3
View File
@@ -18,11 +18,60 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- run: docker build -f pkg/Dockerfile -t ci-image:${{ github.sha }} .
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.15"
- 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.15"
- 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]
needs: [test, integration-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+36 -10
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
python-version: "3.15"
- name: Install dependencies
run: |
pip install -r requirements.txt pytest
@@ -29,34 +29,60 @@ jobs:
- name: Start Qdrant
run: |
docker rm -f qdrant 2>/dev/null || true
docker run -d --name qdrant \
docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true
docker run -d --name qdrant-${{ github.run_id }} \
--network "container:$(hostname)" \
docker.io/qdrant/qdrant:latest
docker.io/qdrant/qdrant:v1.19
- 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)"
# /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"
python-version: "3.15"
- name: Install dependencies
run: |
pip install -r requirements.txt pytest
- name: Run integration tests
run: pytest tests/test_integration.py -v
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 2>/dev/null || true
run: docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true
build:
runs-on: ubuntu-latest
+78 -3
View File
@@ -18,11 +18,86 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- run: docker build -f pkg/Dockerfile -t ci-image:${{ github.sha }} .
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.15"
- 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
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: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.15"
- 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]
needs: [test, integration-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+16
View File
@@ -14,6 +14,22 @@ jobs:
with:
dockerfile: pkg/Dockerfile
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.15"
- 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:
runs-on: ubuntu-latest
steps:
+71 -3
View File
@@ -18,11 +18,60 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- run: docker build -f pkg/Dockerfile -t ci-image:${{ github.sha }} .
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.15"
- 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.15"
- 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]
needs: [test, integration-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -46,3 +95,22 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
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"
+2 -2
View File
@@ -1,6 +1,6 @@
services:
qdrant:
image: docker.io/qdrant/qdrant:latest
image: docker.io/qdrant/qdrant:v1.19
container_name: mcp_maildir_qdrant
ports:
- "127.0.0.1:6333:6333" # REST API
@@ -15,7 +15,7 @@ services:
dockerfile: pkg/Dockerfile
container_name: mcp_maildir_server
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
environment:
+5
View File
@@ -1,3 +1,8 @@
[pytest]
testpaths = tests
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": [
{
"customType": "regex",
"description": "Track qdrant/qdrant Docker image from docker-compose.yml (native manager missed it)",
"managerFilePatterns": ["/^docker-compose\\.ya?ml$/"],
"matchStrings": ["image:\\s*(?:docker\\.io/)?qdrant/qdrant:(?<currentValue>[^\\s]+)"],
"description": "Track the pinned qdrant/qdrant image in docker-compose.yml and the CI workflows (native manager missed it)",
"managerFilePatterns": [
"/^docker-compose\\.ya?ml$/",
"/^\\.gitea/workflows/.*\\.ya?ml$/"
],
"matchStrings": ["(?:docker\\.io/)?qdrant/qdrant:(?<currentValue>[^\\s\"']+)"],
"depNameTemplate": "qdrant/qdrant",
"datasourceTemplate": "docker"
}
+1 -1
View File
@@ -1,4 +1,4 @@
fastmcp>=3,<4
fastmcp>=4,<5
qdrant-client>=1,<2
fastembed>=0.8.0,<1
python-dotenv>=1,<2
+18 -8
View File
@@ -22,26 +22,34 @@ from bs4 import BeautifulSoup
# Load .env config
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_FOLDERS = os.environ.get("MAILDIR_FOLDERS", "")
QDRANT_URL = os.environ.get("QDRANT_URL", "")
COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "")
if not MAILDIR_PATH:
raise ValueError("MAILDIR_PATH environment variable is required.")
if not QDRANT_URL:
raise ValueError("QDRANT_URL environment variable is required.")
if not COLLECTION_NAME:
raise ValueError("COLLECTION_NAME environment variable is required.")
# 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:
raise ValueError("MAILDIR_PATH environment variable is required.")
if not QDRANT_URL:
raise ValueError("QDRANT_URL environment variable is required.")
if not COLLECTION_NAME:
raise ValueError("COLLECTION_NAME environment variable is required.")
def decode_mime_words(s: str) -> str:
"""Decodes MIME encoded strings (e.g. subjects, filenames)."""
if not s:
@@ -251,6 +259,8 @@ def main():
Main ingestion function.
Reads Maildir, extracts text, generates local embeddings, and pushes to Qdrant.
"""
_validate_config()
print(f"Indexing emails from {MAILDIR_PATH} into {QDRANT_URL}...")
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
from dateutil import parser as date_parser
from email.utils import parseaddr
import uvicorn
from dotenv import load_dotenv
from starlette.requests import Request
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")
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:
raise ValueError("QDRANT_URL environment variable is required.")
if not COLLECTION_NAME:
@@ -60,8 +65,8 @@ def get_qdrant_client() -> QdrantClient:
"""Returns a singleton Qdrant client."""
global _qdrant_client
if _qdrant_client is None:
logger.info(f"Connecting to Qdrant at {QDRANT_URL}")
_qdrant_client = QdrantClient(url=QDRANT_URL)
logger.info(f"Connecting to Qdrant at {QDRANT_URL} (timeout={QDRANT_TIMEOUT})")
_qdrant_client = QdrantClient(url=QDRANT_URL, timeout=QDRANT_TIMEOUT)
return _qdrant_client
@@ -340,6 +345,23 @@ def read_email(message_id: str):
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__":
logger.info("Initializing models before starting server...")
try:
@@ -348,6 +370,5 @@ if __name__ == "__main__":
except Exception as e:
logger.error(f"Failed to load models: {e}")
logger.info("Starting SSE server on 0.0.0.0:8000...")
# Start the MCP server using SSE (Server-Sent Events) over HTTP
mcp.run(transport="sse", host="0.0.0.0", port=8000)
logger.info(f"Starting Streamable HTTP server on {MCP_HOST}:{MCP_PORT}{MCP_HTTP_PATH}...")
uvicorn.run(build_app(), host=MCP_HOST, port=MCP_PORT)
+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 uuid
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 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 = [
{
@@ -56,6 +61,21 @@ def qdrant_setup():
model = get_embedding_model()
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
try:
client.delete_collection(collection_name)
@@ -70,6 +90,14 @@ def qdrant_setup():
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
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
os.environ["QDRANT_URL"] = "http://localhost:6333"
os.environ["COLLECTION_NAME"] = "test"
Env vars are set in tests/conftest.py (shared session-wide) — see the
conftest docstring for why test modules must not touch os.environ.
"""
from server import (
normalize_email_address,
payload_matches_participant,
format_search_result,
build_filter,
)
@@ -108,3 +109,55 @@ class TestFormatSearchResult:
assert result["receiver"] is None
assert result["subject"] is None
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()