From fc8058b583ae38e605e81659855d7b75de37acfc Mon Sep 17 00:00:00 2001 From: Christophe Lesur Date: Sun, 30 Aug 2026 11:52:14 +0200 Subject: [PATCH 1/3] fix(storage): nettoyer les ontologies orphelines (v3.2.1) --- CHANGELOG.md | 9 ++++ Dockerfile | 2 +- README.en.md | 4 +- README.md | 6 +-- VERSION | 2 +- src/mcp_memory/__init__.py | 2 +- src/mcp_memory/server.py | 22 ++++++--- src/mcp_memory/static/admin.html | 6 +-- src/mcp_memory/storage_consistency.py | 41 ++++++++++++++++ tests/test_storage_consistency.py | 68 +++++++++++++++++++++++++++ 10 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 src/mcp_memory/storage_consistency.py create mode 100644 tests/test_storage_consistency.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f4966..11428a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [3.2.1] - 2026-08-30 + +### 🧹 Cleanup des ontologies S3 orphelines + +- **`storage_check`** ne considère plus automatiquement tous les objets contenant `_ontology_` comme légitimes : seules les ontologies encore référencées par une mémoire existante sont protégées. +- **`storage_cleanup`** peut désormais détecter et supprimer les ontologies laissées sur S3 après la suppression d'une mémoire, y compris les copies obsolètes d'une ontologie. +- **Compatibilité legacy** : les mémoires anciennes sans `ontology_uri` restent protégées par une correspondance stricte sur leur identifiant et leur nom d'ontologie. +- Correctif de l'issue [#31](https://github.com/Cloud-Temple/graph-memory/issues/31), validé sur Docker local : 5 ontologies orphelines détectées puis supprimées, second contrôle à 0 orphelin. + ## [3.2.0] - 2026-06-04 ### 🧭 `source_path` exposé dans la recherche Graph-first diff --git a/Dockerfile b/Dockerfile index 74932ee..5fb45ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ FROM python:3.11.12-slim # Métadonnées LABEL maintainer="Cloud Temple" LABEL description="MCP Memory Service - Knowledge Graph Memory for AI Agents" -LABEL version="3.2.0" +LABEL version="3.2.1" # Variables d'environnement Python ENV PYTHONDONTWRITEBYTECODE=1 diff --git a/README.en.md b/README.en.md index de29c72..9bc2a99 100644 --- a/README.en.md +++ b/README.en.md @@ -14,9 +14,9 @@ Built by **[Cloud Temple](https://www.cloud-temple.com)**. ## 📋 Changelog -See **[CHANGELOG.md](CHANGELOG.md)** for the full version history (v0.5.0 → v3.2.0). +See **[CHANGELOG.md](CHANGELOG.md)** for the full version history (v0.5.0 → v3.2.1). -**Latest**: v3.2.0 (June 4, 2026) — `source_path` exposed in Graph-first search: `memory_search` and `memory_query` now return the canonical source path (`source_path`) and a derived `repo_path` for every document/chunk, letting an agent open the Git file immediately without a full `document_list`. Enriched via a retroactive graph join (no re-ingestion). `document_get`/`document_list` tools aligned. Previously: v3.1.1 (`/admin` "⚡ Ingest Jobs" console). +**Latest**: v3.2.1 (August 30, 2026) — `storage_check` and `storage_cleanup` now detect S3 ontology objects orphaned after memory deletion, while preserving ontologies still referenced by existing memories and legacy memories without an `ontology_uri`. Fixes [#31](https://github.com/Cloud-Temple/graph-memory/issues/31). Previously: v3.2.0 (`source_path` exposed in Graph-first search). --- diff --git a/README.md b/README.md index bac6c4b..19d4440 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ Développé par **[Cloud Temple](https://www.cloud-temple.com)**. ## 📋 Changelog -Voir **[CHANGELOG.md](CHANGELOG.md)** pour l'historique complet des versions (v0.5.0 → v3.2.0). +Voir **[CHANGELOG.md](CHANGELOG.md)** pour l'historique complet des versions (v0.5.0 → v3.2.1). -**Dernière version** : v3.2.0 (4 juin 2026) — `source_path` exposé dans la recherche Graph-first : `memory_search` et `memory_query` renvoient désormais le chemin source canonique (`source_path`) et un `repo_path` dérivé pour chaque document/chunk, permettant à un agent d'ouvrir immédiatement le fichier Git sans `document_list` complet. Enrichissement par jointure graphe rétroactive (aucune ré-ingestion). Outils `document_get`/`document_list` alignés. Précédemment : v3.1.1 (console `/admin` « ⚡ Ingest Jobs »). +**Dernière version** : v3.2.1 (30 août 2026) — `storage_check` et `storage_cleanup` détectent désormais les ontologies S3 devenues orphelines après la suppression d'une mémoire, tout en protégeant les ontologies encore référencées et les mémoires legacy sans `ontology_uri`. Correctif de l'issue [#31](https://github.com/Cloud-Temple/graph-memory/issues/31). Précédemment : v3.2.0 (`source_path` exposé dans la recherche Graph-first). --- @@ -932,4 +932,4 @@ Développé par **[Cloud Temple](https://www.cloud-temple.com)**. --- -*Graph Memory v3.2.0 — Juin 2026* +*Graph Memory v3.2.1 — Août 2026* diff --git a/VERSION b/VERSION index a4f52a5..e4604e3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.2.0 \ No newline at end of file +3.2.1 diff --git a/src/mcp_memory/__init__.py b/src/mcp_memory/__init__.py index d2c3ec8..0d14752 100644 --- a/src/mcp_memory/__init__.py +++ b/src/mcp_memory/__init__.py @@ -15,5 +15,5 @@ python -m src.mcp_memory.server --port 8002 """ -__version__ = "3.2.0" +__version__ = "3.2.1" __author__ = "Cloud Temple" diff --git a/src/mcp_memory/server.py b/src/mcp_memory/server.py index 81b7b7b..ea47f8d 100644 --- a/src/mcp_memory/server.py +++ b/src/mcp_memory/server.py @@ -26,6 +26,7 @@ from .auth.middleware import AuthMiddleware, LoggingMiddleware, StaticFilesMiddleware from .auth.context import check_memory_access, check_write_permission, check_admin_permission, get_allowed_memory_ids, current_auth from .core.validators import validate_memory_id, validate_filename, validate_document_size, validate_entity_name, validate_backup_id as validate_backup_id_format, check_bootstrap_key_safety +from .storage_consistency import collect_referenced_ontology_keys, is_referenced_ontology_key # ============================================================================= @@ -2226,13 +2227,21 @@ async def storage_check( return admin_err # 1. Récupérer les mémoires à vérifier + all_memories = await get_graph().list_memories() if memory_id: - memory = await get_graph().get_memory(memory_id) + memory = next((mem for mem in all_memories if mem.id == memory_id), None) if not memory: return {"status": "error", "message": f"Mémoire '{memory_id}' non trouvée"} memories = [memory] else: - memories = await get_graph().list_memories() + memories = all_memories + + # Les ontologies ne sont légitimes que si une mémoire existante les + # référence. Les mémoires legacy sans ontology_uri sont protégées par + # un fallback strict sur leur préfixe et leur nom d'ontologie. + referenced_ontology_keys, legacy_ontology_patterns = collect_referenced_ontology_keys( + all_memories, get_storage()._parse_key + ) # 2. Collecter toutes les URIs des documents référencés dans le graphe graph_uris = set() # URIs référencées dans Neo4j @@ -2275,7 +2284,6 @@ async def storage_check( all_graph_uris = set(graph_uris) # Commencer avec celles du scope if memory_id: # Charger les URIs des autres mémoires aussi - all_memories = await get_graph().list_memories() for mem in all_memories: if mem.id == memory_id: continue # Déjà chargé @@ -2310,9 +2318,11 @@ async def storage_check( if key.startswith("_backups/"): continue - # Ignorer les ontologies (fichiers légitimes) - # Le pattern est {hash[:8]}__ontology_{name}.yaml (double _ car hash + _ontology) - if "_ontology_" in key: + # Ignorer uniquement les ontologies encore référencées par une + # mémoire existante. Une ontologie de mémoire supprimée est orpheline. + if is_referenced_ontology_key( + key, referenced_ontology_keys, legacy_ontology_patterns + ): continue # Si la clé n'est pas référencée dans le graphe → orphelin diff --git a/src/mcp_memory/static/admin.html b/src/mcp_memory/static/admin.html index bd94be1..fddb168 100644 --- a/src/mcp_memory/static/admin.html +++ b/src/mcp_memory/static/admin.html @@ -4,7 +4,7 @@ Cloud Temple — Graph Memory Admin - +
@@ -45,7 +45,7 @@

Graph Memory

- - + + diff --git a/src/mcp_memory/storage_consistency.py b/src/mcp_memory/storage_consistency.py new file mode 100644 index 0000000..0673594 --- /dev/null +++ b/src/mcp_memory/storage_consistency.py @@ -0,0 +1,41 @@ +"""Règles pures de classification des objets S3 liés aux mémoires.""" + + +def collect_referenced_ontology_keys(memories, parse_key): + """Retourne les clés d'ontologie légitimes et les fallbacks legacy. + + Les mémoires récentes référencent exactement leur objet via ``ontology_uri``. + Pour une mémoire legacy sans URI, on protège uniquement son propre préfixe + et le nom de son ontologie ; les autres objets ``_ontology_`` restent + éligibles au nettoyage. + """ + referenced_keys = set() + legacy_patterns = [] + + for memory in memories: + ontology_uri = getattr(memory, "ontology_uri", None) + if ontology_uri: + try: + referenced_keys.add(parse_key(ontology_uri)) + continue + except ValueError: + pass + + memory_id = getattr(memory, "id", "") + ontology = getattr(memory, "ontology", "") + if memory_id and ontology: + legacy_patterns.append((f"{memory_id}/", f"__ontology_{ontology}.yaml")) + + return referenced_keys, legacy_patterns + + +def is_referenced_ontology_key(key, referenced_keys, legacy_patterns): + """Indique si une clé d'ontologie est encore rattachée à une mémoire.""" + if "_ontology_" not in key: + return False + if key in referenced_keys: + return True + return any( + key.startswith(prefix) and key.endswith(suffix) + for prefix, suffix in legacy_patterns + ) diff --git a/tests/test_storage_consistency.py b/tests/test_storage_consistency.py new file mode 100644 index 0000000..9898a68 --- /dev/null +++ b/tests/test_storage_consistency.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace + +from src.mcp_memory.storage_consistency import ( + collect_referenced_ontology_keys, + is_referenced_ontology_key, +) + + +def _parse_key(value: str) -> str: + if value.startswith("s3://"): + parts = value[5:].split("/", 1) + if len(parts) != 2: + raise ValueError(value) + return parts[1] + return value + + +def test_referenced_ontology_is_protected_but_duplicate_is_orphan(): + memory = SimpleNamespace( + id="active-memory", + ontology="software-development", + ontology_uri=( + "s3://bucket/active-memory/documents/aaaa1111_" + "_ontology_software-development.yaml" + ), + ) + referenced, legacy = collect_referenced_ontology_keys([memory], _parse_key) + + assert is_referenced_ontology_key( + "active-memory/documents/aaaa1111__ontology_software-development.yaml", + referenced, + legacy, + ) + assert not is_referenced_ontology_key( + "active-memory/documents/bbbb2222__ontology_software-development.yaml", + referenced, + legacy, + ) + + +def test_deleted_memory_ontology_is_orphan(): + referenced, legacy = collect_referenced_ontology_keys([], _parse_key) + + assert not is_referenced_ontology_key( + "deleted-memory/documents/aaaa1111__ontology_general.yaml", + referenced, + legacy, + ) + + +def test_legacy_memory_without_uri_protects_only_its_expected_ontology(): + memory = SimpleNamespace( + id="legacy-memory", + ontology="legal", + ontology_uri=None, + ) + referenced, legacy = collect_referenced_ontology_keys([memory], _parse_key) + + assert is_referenced_ontology_key( + "legacy-memory/documents/aaaa1111__ontology_legal.yaml", + referenced, + legacy, + ) + assert not is_referenced_ontology_key( + "deleted-memory/documents/aaaa1111__ontology_legal.yaml", + referenced, + legacy, + ) From 2906c3c4d79ad1891ed1660b7f62353969c843c5 Mon Sep 17 00:00:00 2001 From: Christophe Lesur Date: Mon, 31 Aug 2026 17:00:57 +0200 Subject: [PATCH 2/3] fix: migrer vers MCP SDK 2 dans v3.2.1 --- CHANGELOG.md | 12 ++- DESIGN/SPECIFICATION.md | 2 +- Dockerfile | 7 +- README.en.md | 20 ++-- README.md | 18 +++- requirements.lock | 87 ++++++++++++++++ requirements.txt | 5 +- scripts/cli/client.py | 63 ++++-------- scripts/cli/ingest_progress.py | 2 +- src/mcp_memory/auth/middleware.py | 32 +++--- src/mcp_memory/server.py | 37 +++---- tests/test_mcp_sdk2.py | 162 ++++++++++++++++++++++++++++++ 12 files changed, 352 insertions(+), 95 deletions(-) create mode 100644 requirements.lock create mode 100644 tests/test_mcp_sdk2.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 11428a5..78bb75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # Changelog -## [3.2.1] - 2026-08-30 +## [3.2.1] - 2026-08-31 + +### Migration vers MCP Python SDK 2 + +- **SDK officiel `mcp==2.1.1`** : serveur `MCPServer`, version applicative annoncée à l'initialisation, endpoint `/mcp` et 40 outils métier conservés. +- **CLI Python** : transport `streamable_http_client` avec `httpx2`, callback public de progression et lecture des résultats SDK 2 ; timeout de lecture de 15 minutes et désactivation des proxies d'environnement conservés. +- **Console `/admin`** : appels via l'API publique `mcp.call_tool`, validation des arguments et suppression de l'accès au registre privé du SDK. +- **Gros documents** : limite HTTP explicitement alignée sur les 50 Mio applicatifs encodés en base64, pour éviter la nouvelle limite SDK de 4 Mio. +- **Dépendances figées** : `requirements.lock` inclut les dépendances transitives et l'outillage Python ; Docker l'installe sans `pip --upgrade` non borné et vérifie les imports SDK 2 au build. +- Migration interne : pas de changement des paramètres métier ni de migration des données. Les environnements exécutant la CLI Python doivent réinstaller les dépendances. +- **Validation** : 9 tests ciblés passent sous Python 3.11/Docker (authentification, admin, protocoles legacy/2026, notifications, uploads >4 Mio et limite HTTP), build sans cache réussi, schémas des 40 outils inchangés. La recette complète avec LLM reste à rejouer après résolution des erreurs 401 LLMaaS locales. ### 🧹 Cleanup des ontologies S3 orphelines diff --git a/DESIGN/SPECIFICATION.md b/DESIGN/SPECIFICATION.md index fc4aa0a..f6493c8 100644 --- a/DESIGN/SPECIFICATION.md +++ b/DESIGN/SPECIFICATION.md @@ -195,7 +195,7 @@ Le canal de collaboration `graph_push` entre Live Memory et Graph Memory est un | Composant | Technologie | Version | | --------------- | ----------------------------- | --------------------- | | Runtime | Python | 3.11+ | -| MCP SDK | `mcp` (FastMCP) | ≥ 1.8.0 | +| MCP SDK | `mcp` (MCPServer) | 2.1.1 (SDK 2) | | Web Framework | FastAPI + Starlette | ≥ 0.100.0 | | ASGI Server | Uvicorn | ≥ 0.20.0 | | Graph Database | Neo4j Community | 5.x | diff --git a/Dockerfile b/Dockerfile index 5fb45ee..d83c5c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,9 +29,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # Copie et installation des dépendances Python -COPY requirements.txt . -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -r requirements.txt +COPY requirements.txt requirements.lock ./ +RUN pip install --no-cache-dir -r requirements.txt -r requirements.lock \ + && pip check \ + && python -c "from mcp.server.mcpserver import MCPServer, Context; from mcp.client.streamable_http import streamable_http_client" # Créer un utilisateur non-root pour la sécurité RUN groupadd -r mcp && useradd -r -g mcp -d /app -s /sbin/nologin mcp diff --git a/README.en.md b/README.en.md index 9bc2a99..083612a 100644 --- a/README.en.md +++ b/README.en.md @@ -16,7 +16,7 @@ Built by **[Cloud Temple](https://www.cloud-temple.com)**. See **[CHANGELOG.md](CHANGELOG.md)** for the full version history (v0.5.0 → v3.2.1). -**Latest**: v3.2.1 (August 30, 2026) — `storage_check` and `storage_cleanup` now detect S3 ontology objects orphaned after memory deletion, while preserving ontologies still referenced by existing memories and legacy memories without an `ontology_uri`. Fixes [#31](https://github.com/Cloud-Temple/graph-memory/issues/31). Previously: v3.2.0 (`source_path` exposed in Graph-first search). +**Latest**: v3.2.1 (August 31, 2026) — `storage_check` and `storage_cleanup` now detect S3 ontology objects orphaned after memory deletion, while preserving ontologies still referenced by existing memories and legacy memories without an `ontology_uri`. Fixes [#31](https://github.com/Cloud-Temple/graph-memory/issues/31). Previously: v3.2.0 (`source_path` exposed in Graph-first search). --- @@ -144,17 +144,24 @@ open http://localhost:8070/admin ### With Python (MCP SDK) +Version 3.2.1 uses the official MCP SDK **2.1.1**. Reinstall CLI dependencies with +`pip install -r requirements.txt -r requirements.lock`. Existing MCP clients, +tool names and arguments remain supported. Docker uses the same Python +dependency lock. + ```python -from mcp.client.streamable_http import streamablehttp_client +import httpx2 +from mcp.client.streamable_http import streamable_http_client from mcp import ClientSession import base64 async def example(): headers = {"Authorization": "Bearer your_token"} - async with streamablehttp_client( - "http://localhost:8070/mcp", headers=headers - ) as (read, write, _): + async with ( + httpx2.AsyncClient(headers=headers, timeout=httpx2.Timeout(30, read=900), trust_env=False) as http, + streamable_http_client("http://localhost:8070/mcp", http_client=http) as (read, write), + ): async with ClientSession(read, write) as session: await session.initialize() @@ -225,7 +232,8 @@ Custom ontologies can be added as YAML files in `ONTOLOGIES/`. ```bash # Install CLI dependencies -pip install httpx click rich prompt_toolkit mcp +pip install -r requirements.txt -r requirements.lock +pip install prompt_toolkit # Scriptable mode python scripts/mcp_cli.py health diff --git a/README.md b/README.md index 19d4440..d01d06f 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Développé par **[Cloud Temple](https://www.cloud-temple.com)**. Voir **[CHANGELOG.md](CHANGELOG.md)** pour l'historique complet des versions (v0.5.0 → v3.2.1). -**Dernière version** : v3.2.1 (30 août 2026) — `storage_check` et `storage_cleanup` détectent désormais les ontologies S3 devenues orphelines après la suppression d'une mémoire, tout en protégeant les ontologies encore référencées et les mémoires legacy sans `ontology_uri`. Correctif de l'issue [#31](https://github.com/Cloud-Temple/graph-memory/issues/31). Précédemment : v3.2.0 (`source_path` exposé dans la recherche Graph-first). +**Dernière version** : v3.2.1 (31 août 2026) — `storage_check` et `storage_cleanup` détectent désormais les ontologies S3 devenues orphelines après la suppression d'une mémoire, tout en protégeant les ontologies encore référencées et les mémoires legacy sans `ontology_uri`. Correctif de l'issue [#31](https://github.com/Cloud-Temple/graph-memory/issues/31). Précédemment : v3.2.0 (`source_path` exposé dans la recherche Graph-first). --- @@ -353,7 +353,8 @@ Interfaces disponibles : ### Installation des dépendances CLI ```bash -pip install httpx click rich prompt_toolkit mcp +pip install -r requirements.txt -r requirements.lock +pip install prompt_toolkit ``` ### Mode Click (scriptable) @@ -666,15 +667,24 @@ Ajoutez dans votre configuration MCP : ### Via Python (client MCP) +La v3.2.1 utilise le SDK officiel MCP **2.1.1**. Réinstaller les dépendances de la +CLI avec `pip install -r requirements.txt -r requirements.lock`. Le serveur reste +compatible avec les clients MCP existants ; les noms et arguments des 40 outils +ne changent pas. Docker utilise le même verrouillage des dépendances Python. + ```python -from mcp.client.streamable_http import streamablehttp_client +import httpx2 +from mcp.client.streamable_http import streamable_http_client from mcp import ClientSession import base64 async def exemple(): headers = {"Authorization": "Bearer votre_token"} - async with streamablehttp_client("http://localhost:8070/mcp", headers=headers) as (read, write, _): + async with ( + httpx2.AsyncClient(headers=headers, timeout=httpx2.Timeout(30, read=900), trust_env=False) as http, + streamable_http_client("http://localhost:8070/mcp", http_client=http) as (read, write), + ): async with ClientSession(read, write) as session: await session.initialize() diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..6723e20 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,87 @@ +# Python 3.11 / Linux. Base : image locale 3.2.1, puis migration mcp==2.1.1. +# Capture avec python -m pip freeze --all : runtime et outils de build figés. +# Docker installe ce fichier ET requirements.txt pour détecter toute divergence. +aiofiles==25.1.0 +aiohappyeyeballs==2.6.2 +aiohttp==3.14.0 +aiosignal==1.4.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +attrs==26.1.0 +beautifulsoup4==4.14.3 +boto3==1.43.21 +botocore==1.43.21 +certifi==2026.5.20 +cffi==2.0.0 +click==8.4.1 +cryptography==48.0.0 +distro==1.9.0 +fastapi==0.136.3 +frozenlist==1.8.0 +grpcio==1.81.0 +h11==0.16.0 +h2==4.3.0 +hpack==4.1.0 +httpcore==1.0.9 +httpcore2==2.12.0 +httptools==0.8.0 +httpx==0.28.1 +httpx-sse==0.4.3 +httpx2==2.12.0 +hyperframe==6.1.0 +idna==3.18 +jiter==0.15.0 +jmespath==1.1.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +lxml==6.1.1 +markdown-it-py==4.2.0 +mcp==2.1.1 +mcp-types==2.1.1 +mdurl==0.1.2 +multidict==6.7.1 +neo4j==6.2.0 +numpy==2.4.6 +openai==2.40.0 +opentelemetry-api==1.44.0 +pip==26.1.2 +portalocker==3.2.0 +propcache==0.5.2 +protobuf==7.35.0 +pycparser==3.0 +pydantic==2.13.4 +pydantic-settings==2.14.1 +pydantic_core==2.46.4 +Pygments==2.20.0 +PyJWT==2.13.0 +pypdf==6.12.2 +python-dateutil==2.9.0.post0 +python-docx==1.2.0 +python-dotenv==1.2.2 +python-multipart==0.0.30 +pytz==2026.2 +PyYAML==6.0.3 +qdrant-client==1.18.0 +referencing==0.37.0 +rich==15.0.0 +rpds-py==2026.5.1 +s3transfer==0.18.0 +setuptools==65.5.1 +six==1.17.0 +sniffio==1.3.1 +soupsieve==2.8.4 +sse-starlette==3.4.4 +starlette==1.2.1 +tenacity==9.1.4 +tqdm==4.67.3 +truststore==0.10.4 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.7.0 +uvicorn==0.48.0 +uvloop==0.22.1 +watchfiles==1.2.0 +websockets==16.0 +wheel==0.45.1 +yarl==1.24.2 diff --git a/requirements.txt b/requirements.txt index 65556f6..8b9c284 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,15 +5,16 @@ # ============================================================================= # === MCP SDK (Streamable HTTP transport) === -mcp>=1.8.0 +mcp==2.1.1 -# === Web Framework (pour FastMCP Streamable HTTP) === +# === Web Framework (pour MCPServer Streamable HTTP) === fastapi>=0.100.0 uvicorn[standard]>=0.20.0 starlette>=0.27.0 # === HTTP Client (async) === httpx>=0.27.0 +httpx2>=2.5.0,<3 # Transport du SDK MCP 2 ; httpx reste utilisé par les autres clients # === Neo4j Driver === neo4j>=5.0.0 diff --git a/scripts/cli/client.py b/scripts/cli/client.py index 203b539..f75a41a 100644 --- a/scripts/cli/client.py +++ b/scripts/cli/client.py @@ -92,65 +92,46 @@ async def on_progress(message: str) -> None """ import asyncio import sys - import httpx + import httpx2 from mcp import ClientSession - from mcp.client.streamable_http import streamablehttp_client + from mcp.client.streamable_http import streamable_http_client headers = {"Authorization": f"Bearer {self.token}"} - def _httpx_client_factory(headers=None, timeout=None, auth=None): - return httpx.AsyncClient( - follow_redirects=True, - headers=headers, - timeout=timeout, - auth=auth, - trust_env=False, - ) + async def _on_log(params): + if on_progress and params.data: + try: + await on_progress(str(params.data)) + except Exception: + pass # Une erreur d'affichage ne doit pas interrompre l'opération. last_error = None for attempt in range(1, max_retries + 1): try: - async with streamablehttp_client( - f"{self.base_url}/mcp", + async with httpx2.AsyncClient( headers=headers, - timeout=30, # connexion initiale : 30s - sse_read_timeout=900, # attente réponse : 15 min (extraction LLM de gros docs) - httpx_client_factory=_httpx_client_factory, - ) as (read, write, _): - async with ClientSession(read, write) as session: + timeout=httpx2.Timeout(30, read=900), + follow_redirects=True, + trust_env=False, + ) as http_client, streamable_http_client( + f"{self.base_url}/mcp", http_client=http_client, + ) as (read, write): + async with ClientSession( + read, write, logging_callback=_on_log, + log_level="info" if on_progress else None, + ) as session: await session.initialize() - # Capturer les notifications de progression (ctx.info()) - # Le SDK MCP expose _received_notification() comme hook surchargeable - if on_progress: - _original_received = session._received_notification - - async def _patched_received_notification(notification): - try: - # Le SDK wrappe dans un type union : notification.root - # est le vrai objet (ex: LoggingMessageNotification) - root = getattr(notification, 'root', notification) - params = getattr(root, 'params', None) - if params: - # ctx.info() → LoggingMessageNotification.params.data - msg = getattr(params, 'data', None) - if msg: - await on_progress(str(msg)) - except Exception: - pass - # Appeler le handler original - await _original_received(notification) - - session._received_notification = _patched_received_notification - result = await session.call_tool(tool_name, args) # --- Parsing robuste de la réponse MCP --- # Vérifier si le serveur a renvoyé une erreur - if getattr(result, 'isError', False): + if result.is_error: error_msg = "Erreur serveur MCP" if result.content: error_msg = getattr(result.content[0], 'text', '') or error_msg return {"status": "error", "message": error_msg} + if result.structured_content is not None: + return result.structured_content # Extraire le texte du premier bloc de contenu text = "" if result.content: diff --git a/scripts/cli/ingest_progress.py b/scripts/cli/ingest_progress.py index 587dd97..d847ff9 100644 --- a/scripts/cli/ingest_progress.py +++ b/scripts/cli/ingest_progress.py @@ -71,7 +71,7 @@ def create_progress_callback(state: dict): et met à jour l'état de progression. Les messages proviennent de ctx.info() côté serveur MCP et sont - interceptés via le hook _received_notification du SDK MCP. + reçus via le callback public logging_callback du SDK MCP. Args: state: dict créé par create_progress_state() diff --git a/src/mcp_memory/auth/middleware.py b/src/mcp_memory/auth/middleware.py index de628a6..4a4c356 100644 --- a/src/mcp_memory/auth/middleware.py +++ b/src/mcp_memory/auth/middleware.py @@ -511,27 +511,21 @@ async def _api_tool(self, send, body: bytes): await self._send_json(send, {"status": "error", "message": "Erreur interne /api/tool"}, 500) async def _call_tool_direct(self, tool_name: str, arguments: dict) -> dict: - """Appelle directement un outil enregistré dans FastMCP.""" + """Appelle l'API publique du SDK, avec validation des arguments et contexte.""" from ..server import mcp + from mcp.server.mcpserver.exceptions import ToolError - tool_manager = mcp._tool_manager - tools = getattr(tool_manager, "_tools", {}) - if tool_name not in tools: - return {"status": "error", "message": f"Outil inconnu: {tool_name}"} - - tool_obj = tools[tool_name] - fn = None - for attr in ("fn", "func", "handler", "_fn", "run", "callback"): - candidate = getattr(tool_obj, attr, None) - if candidate and callable(candidate): - fn = candidate - break - - if fn is None: - return {"status": "error", "message": f"Outil {tool_name}: handler introuvable"} - - result = await fn(**arguments) - return result if isinstance(result, dict) else {"status": "ok", "data": result} + try: + result = await mcp.call_tool(tool_name, arguments) + except ToolError as exc: + return {"status": "error", "message": str(exc)} + if result.is_error: + message = next((block.text for block in result.content if block.type == "text"), "Erreur serveur MCP") + return {"status": "error", "message": message} + if result.structured_content is not None: + return result.structured_content + text = next((block.text for block in result.content if block.type == "text"), "") + return json.loads(text) if text else {"status": "error", "message": "Réponse vide du serveur"} def _read_version(self) -> str: """Lit la version depuis le fichier VERSION.""" diff --git a/src/mcp_memory/server.py b/src/mcp_memory/server.py index ea47f8d..96a69aa 100644 --- a/src/mcp_memory/server.py +++ b/src/mcp_memory/server.py @@ -2,7 +2,7 @@ """ MCP Memory Server - Serveur principal. -Expose tous les outils MCP via Streamable HTTP avec FastMCP. +Expose tous les outils MCP via Streamable HTTP avec le SDK MCP 2. """ import os @@ -20,8 +20,9 @@ # Charger .env avant les imports qui en dépendent load_dotenv() -from mcp.server.fastmcp import FastMCP, Context +from mcp.server.mcpserver import MCPServer, Context +from . import __version__ from .config import get_settings from .auth.middleware import AuthMiddleware, LoggingMiddleware, StaticFilesMiddleware from .auth.context import check_memory_access, check_write_permission, check_admin_permission, get_allowed_memory_ids, current_auth @@ -35,12 +36,10 @@ settings = get_settings() -# Créer l'instance FastMCP -# host="0.0.0.0" pour accepter les connexions externes (reverse proxy, Docker) -mcp = FastMCP( +# Le binding HTTP est configuré à la création de l'app et dans Uvicorn. +mcp = MCPServer( name=settings.mcp_server_name, - host=settings.mcp_server_host, - port=settings.mcp_server_port, + version=__version__, ) @@ -3104,6 +3103,18 @@ async def _progress(msg): # Point d'entrée # ============================================================================= +def create_app(*, host: str, debug: bool = False): + """Construit la pile ASGI commune au serveur et aux tests de transport.""" + base_app = mcp.streamable_http_app( + host=host, + # Préserver les uploads de 50 Mio encodés en base64 (défaut SDK 2 : 4 Mio). + max_request_body_size=int(settings.max_document_size_bytes * 1.5), + ) + app = StaticFilesMiddleware(base_app) + app = LoggingMiddleware(app, debug=debug) + return AuthMiddleware(app, debug=debug) + + def main(): """Point d'entrée principal.""" parser = argparse.ArgumentParser(description="MCP Memory Server") @@ -3112,16 +3123,8 @@ def main(): parser.add_argument("--debug", action="store_true", default=settings.mcp_server_debug) args = parser.parse_args() - # Récupérer l'app ASGI Streamable HTTP de FastMCP - # Remplace l'ancien mcp.sse_app() — endpoint unique /mcp au lieu de /sse + /messages - # Le HostNormalizerMiddleware n'est plus nécessaire (plus de validation Host par Starlette) - base_app = mcp.streamable_http_app() - - # Empiler les middlewares (le dernier wrappé est le premier exécuté) - # Flux requête : AuthMiddleware → LoggingMiddleware → StaticFilesMiddleware → MCP Streamable HTTP app - app = StaticFilesMiddleware(base_app) - app = LoggingMiddleware(app, debug=args.debug) - app = AuthMiddleware(app, debug=args.debug) + # Auth → logs → routes web/admin → MCP Streamable HTTP. + app = create_app(host=args.host, debug=args.debug) # Sécurité v2.1.0 : vérifier la clé bootstrap au démarrage check_bootstrap_key_safety(settings.admin_bootstrap_key or "") diff --git a/tests/test_mcp_sdk2.py b/tests/test_mcp_sdk2.py new file mode 100644 index 0000000..0f4492d --- /dev/null +++ b/tests/test_mcp_sdk2.py @@ -0,0 +1,162 @@ +"""Contrats HTTP réels du SDK 2, sans S3, LLM ou base externe.""" + +import asyncio +import json +import socket +from types import SimpleNamespace + +import httpx2 +import pytest +import pytest_asyncio +import uvicorn +from mcp import Client, ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.server.mcpserver import Context + +from scripts.cli.client import MCPClient + + +@pytest_asyncio.fixture +async def service(monkeypatch): + for name in ("S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "LLMAAS_API_KEY", "NEO4J_PASSWORD"): + monkeypatch.setenv(name, "sdk2-test-unused") + monkeypatch.setenv("LOCALHOST_AUTH_BYPASS", "false") + from src.mcp_memory import server + from src.mcp_memory.auth.context import current_auth + + monkeypatch.setattr(server.settings, "admin_bootstrap_key", "sdk2-test-admin") + + class Tokens: + async def validate_token(self, token): + if token not in ("reader", "writer"): + return None + return SimpleNamespace( + client_name=token, token_hash=token, + permissions=["read"] if token == "reader" else ["read", "write"], + memory_ids=["allowed"], + ) + + async def list_tokens(self, **kwargs): + return [] + + tokens = Tokens() + monkeypatch.setattr(server, "_token_manager", tokens) + + @server.mcp.tool() + async def sdk2_probe(payload: str, ctx: Context) -> dict: + await ctx.info("probe-progress") + return {"status": "ok", "size": len(payload), "client": current_auth.get()["client_name"]} + + @server.mcp.tool() + async def sdk2_failure() -> dict: + raise ValueError("probe-failure") + + app = server.create_app(host="0.0.0.0") + app._token_manager = tokens + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen() + port = sock.getsockname()[1] + runner = uvicorn.Server(uvicorn.Config(app, log_level="error", lifespan="on")) + task = asyncio.create_task(runner.serve(sockets=[sock])) + try: + async with asyncio.timeout(10): + while not runner.started: + if task.done(): + await task + await asyncio.sleep(0.01) + yield f"http://127.0.0.1:{port}", server + finally: + runner.should_exit = True + await asyncio.wait_for(task, 10) + sock.close() + server.mcp.remove_tool("sdk2_probe") + server.mcp.remove_tool("sdk2_failure") + + +@pytest.mark.asyncio +async def test_sdk2_cli_large_request_progress_and_errors(service, monkeypatch): + url, _ = service + # Un proxy du poste ne doit pas détourner les requêtes locales. + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:1") + monkeypatch.setenv("NO_PROXY", "") + messages = [] + + async def progress(message): + messages.append(message) + + client = MCPClient(url, "writer") + result = await client.call_tool("sdk2_probe", {"payload": "x" * (5 * 1024 * 1024)}, on_progress=progress) + assert result == {"status": "ok", "size": 5 * 1024 * 1024, "client": "writer"} + assert "probe-progress" in messages + failure = await client.call_tool("sdk2_failure", {}) + assert failure["status"] == "error" + assert "sdk2_failure" in failure["message"] + assert "probe-failure" not in failure["message"] # Le SDK masque les erreurs internes. + invalid = await client.call_tool("sdk2_probe", {}) + assert invalid["status"] == "error" + assert "payload" in invalid["message"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["legacy", "2026-07-28"]) +async def test_protocol_versions_auth_and_tool_schemas(service, mode): + url, _ = service + async with httpx2.AsyncClient(headers={"Authorization": "Bearer reader"}, trust_env=False) as http: + transport = streamable_http_client(f"{url}/mcp", http_client=http) + async with Client(transport, mode=mode) as client: + tools = await client.list_tools() + assert len(tools.tools) == 42 # 40 outils métier + 2 sondes de transport. + assert all("ctx" not in tool.input_schema.get("properties", {}) for tool in tools.tools) + result = await client.call_tool("system_whoami", {}) + identity = json.loads(result.content[0].text) + assert identity["client_name"] == "reader" + assert identity["permissions"] == ["read"] + denied = await client.call_tool("memory_stats", {"memory_id": "forbidden"}) + denial = json.loads(denied.content[0].text) + assert denial["status"] == "error" + assert "Accès refusé" in denial["message"] + + +@pytest.mark.asyncio +async def test_admin_cookie_and_public_sdk_dispatch(service): + url, _ = service + async with httpx2.AsyncClient(base_url=url, trust_env=False) as http: + assert (await http.get("/admin")).status_code == 200 + assert (await http.post("/api/tool", json={"tool": "system_whoami"})).status_code == 401 + login = await http.post("/api/login", json={"token": "sdk2-test-admin"}) + assert login.status_code == 200 + assert "httponly" in login.headers["set-cookie"].lower() + result = await http.post("/api/tool", json={"tool": "system_whoami"}) + assert result.status_code == 200 + assert result.json()["auth_type"] == "bootstrap" + invalid = await http.post("/api/tool", json={"tool": "memory_stats", "arguments": {}}) + assert invalid.status_code == 200 + assert invalid.json()["status"] == "error" + unknown = await http.post("/api/tool", json={"tool": "does_not_exist"}) + assert unknown.status_code == 200 + assert unknown.json()["status"] == "error" + await http.post("/api/logout") + assert (await http.post("/api/tool", json={"tool": "system_whoami"})).status_code == 401 + + +@pytest.mark.asyncio +async def test_anonymous_mcp_denied_and_http_body_limit(service): + url, server = service + async with httpx2.AsyncClient(base_url=url, trust_env=False, timeout=20) as http: + assert (await http.post("/mcp", json={})).status_code == 401 + oversized = await http.post( + "/mcp", headers={"Authorization": "Bearer sdk2-test-admin"}, + content=b"x" * (int(server.settings.max_document_size_bytes * 1.5) + 1), + ) + assert oversized.status_code == 413 + + +@pytest.mark.asyncio +async def test_initialize_reports_application_version(service): + url, _ = service + async with httpx2.AsyncClient(headers={"Authorization": "Bearer sdk2-test-admin"}, trust_env=False) as http: + async with streamable_http_client(f"{url}/mcp", http_client=http) as (read, write): + async with ClientSession(read, write) as session: + result = await session.initialize() + assert result.server_info.version == "3.2.1" From 942ac93ac1471efb00cb7985b939bd6baaf538ab Mon Sep 17 00:00:00 2001 From: Christophe Lesur Date: Mon, 31 Aug 2026 19:28:21 +0200 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20consigner=20la=20recette=20r=C3=A9e?= =?UTF-8?q?lle=20MCP=20SDK=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78bb75e..8c23ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ - **Gros documents** : limite HTTP explicitement alignée sur les 50 Mio applicatifs encodés en base64, pour éviter la nouvelle limite SDK de 4 Mio. - **Dépendances figées** : `requirements.lock` inclut les dépendances transitives et l'outillage Python ; Docker l'installe sans `pip --upgrade` non borné et vérifie les imports SDK 2 au build. - Migration interne : pas de changement des paramètres métier ni de migration des données. Les environnements exécutant la CLI Python doivent réinstaller les dépendances. -- **Validation** : 9 tests ciblés passent sous Python 3.11/Docker (authentification, admin, protocoles legacy/2026, notifications, uploads >4 Mio et limite HTTP), build sans cache réussi, schémas des 40 outils inchangés. La recette complète avec LLM reste à rejouer après résolution des erreurs 401 LLMaaS locales. +- **Validation** : 9 tests ciblés passent sous Python 3.11/Docker (authentification, admin, protocoles legacy/2026, notifications, uploads >4 Mio et limite HTTP), build sans cache réussi, schémas des 40 outils inchangés. Après renouvellement de la clé LLMaaS, 19 contrôles fonctionnels réels passent avec le CLI Go et le serveur SDK 2 : ingestion de 3 documents, extraction d'entités et relations, recherche graphe et vectorielle, déduplication locale/distante, remplacement explicite et cohérence S3/Neo4j/Qdrant. +- **Réserve de recette locale** : les accès TLS/S3 présentent des délais intermittents, reproduits également avec le serveur SDK 1. Le nettoyage a dépassé le délai client de 120 secondes ; la suppression effective des ressources de test a été vérifiée séparément (aucun résidu S3, Neo4j ou Qdrant). La suite de recette globale n'a pas été rejouée. ### 🧹 Cleanup des ontologies S3 orphelines