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 / 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) Waiting to run
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
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
8 changed files with 141 additions and 21 deletions

No files matched your search

+30 -4
View File
@@ -32,15 +32,32 @@ jobs:
docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true docker rm -f qdrant-${{ github.run_id }} 2>/dev/null || true
docker run -d --name qdrant-${{ github.run_id }} \ 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:
@@ -53,6 +70,15 @@ jobs:
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()
+30 -6
View File
@@ -45,17 +45,32 @@ jobs:
run: | run: |
docker run -d --name qdrant-${{ github.run_id }} \ 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 \ # container on a loaded host can take several seconds to create its
&& echo "QDRANT ready" && break # first collection. Gate on a real write round-trip, not on /healthz.
echo "Waiting for Qdrant... ($i/30)" 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
curl -s http://localhost:6333/healthz > /dev/null 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:
@@ -67,6 +82,15 @@ jobs:
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()
+19
View File
@@ -95,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"
+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
+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"
} }
+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)
+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()