Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,12 @@ ADMIN_BOOTSTRAP_KEY=change_me_in_production

# Timeout requêtes Neo4j en secondes (défaut: 30)
# NEO4J_QUERY_TIMEOUT_SECONDS=30

# =============================================================================
# Proxy HTTP sortant (optionnel)
# =============================================================================
# Proxy HTTP pour les appels S3 (boto3), LLM et embeddings (httpx/openai).
# Variable CUSTOM — pas HTTP_PROXY/HTTPS_PROXY — injectée manuellement pour
# ne pas affecter toutes les autres libs Python.
# ⚠️ Non supporté pour Neo4j (driver bolt) et Qdrant (client natif).
# #PROXY_URL=http://10.185.132.250:3128
100 changes: 100 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: CI — Build & Push

# Déclencheurs :
# - push sur n'importe quelle branche → image taguée avec le nom de branche
# - push d'un tag v* → tags selon la convention ci-dessous
#
# Convention de tags Docker :
# ┌─────────────────────┬─────────────────────────────────────────────────────┐
# │ Git ref │ Tags Docker produits │
# ├─────────────────────┼─────────────────────────────────────────────────────┤
# │ branch: main │ :main │
# │ branch: dev │ :dev │
# │ branch: feature/foo │ :feature-foo │
# │ tag: v2.1.1 │ :2.1.1 :2.1 :latest (release stable) │
# │ tag: v2.1.1-rc.1 │ :2.1.1-rc.1 (pre-release, pas d'alias) │
# │ tag: v2.1.1-dev │ :2.1.1-dev (pre-release, pas d'alias) │
# └─────────────────────┴─────────────────────────────────────────────────────┘
# Règle : un tag contenant un tiret (vX.Y.Z-*) est une pré-release →
# pas de :X.Y ni de :latest pour éviter de pointer une version instable.
on:
push:
branches:
- "**"
tags:
- "v*"

env:
REGISTRY: ghcr.io
# github.repository → "cloud-temple/graph-memory"
# Image finale → ghcr.io/cloud-temple/graph-memory:<tag>
IMAGE_NAME: ${{ github.repository }}

jobs:
# ─────────────────────────────────────────────────────────────────────────
# Job — Build multi-arch + push GHCR
# ─────────────────────────────────────────────────────────────────────────
build:
name: Build & Push Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Requis pour pousser vers GHCR

steps:
- uses: actions/checkout@v4

# Multi-arch : amd64 natif + arm64 via QEMU (Mac M-series, Pi, etc.)
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# Authentification GHCR — GITHUB_TOKEN suffit, aucun secret à configurer
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Génération automatique des tags et labels OCI
# IS_STABLE_TAG = true ssi le ref est un tag vX.Y.Z SANS tiret (release stable).
# Les pré-releases (v2.1.1-rc.1, v2.1.1-dev, …) ont un tiret → IS_STABLE_TAG=false.
- name: Detect stable tag
id: tag_type
run: |
REF="${{ github.ref }}"
if [[ "$REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "is_stable=true" >> "$GITHUB_OUTPUT"
else
echo "is_stable=false" >> "$GITHUB_OUTPUT"
fi

- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Branches → tag avec le nom de branche (main, dev, feature-xxx, …)
type=ref,event=branch
# Tags semver → version complète (ex: v2.1.1 → 2.1.1 ; v2.1.1-rc.1 → 2.1.1-rc.1)
type=semver,pattern={{version}}
# :X.Y uniquement pour les releases stables (pas de tiret dans le tag)
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.tag_type.outputs.is_stable }}
# :latest uniquement pour les releases stables (pas de tiret dans le tag)
type=raw,value=latest,enable=${{ steps.tag_type.outputs.is_stable }}

- name: Build and Push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Cache BuildKit via GitHub Actions Cache → rebuilds ~3× plus rapides
cache-from: type=gha
cache-to: type=gha,mode=max
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# Changelog

## [Unreleased]

### Added
- **PROXY_URL** — variable d'environnement optionnelle pour router les appels
S3 (boto3 SigV2 + SigV4), LLM extraction (`ExtractorService`) et embeddings
(`EmbeddingService`) via un proxy HTTP sortant.
- Injectée manuellement (`boto3.Config(proxies=...)`, `httpx.AsyncClient(proxy=...)`)
pour ne **pas** affecter les autres libs Python qui lisent automatiquement
`HTTP_PROXY` / `HTTPS_PROXY`.
- Validation fail-fast au démarrage : doit commencer par `http://` ou `https://`.
- Non supporté pour Neo4j (driver bolt) et Qdrant (client natif sans param proxy).
- Log au démarrage si active : `[StorageService] S3 requests via proxy …`

### Fixed
- **`Dockerfile` : suppression du `HEALTHCHECK`** — Le healthcheck était hardcodé sur
le port 8002 dans l'image, provoquant l'arrêt du container (ExitCode=0 via SIGTERM
Swarm) dès que `MCP_SERVER_PORT` était différent de 8002. La responsabilité du
healthcheck appartient à l'orchestrateur (`docker-compose.yml`, Swarm stack, Kubernetes
probes) qui connaît la configuration réelle du déploiement.
- **`docker-compose.yml` : healthcheck via `python` au lieu de `curl`** — Plus de
dépendance à un binaire externe ; disponible dans toute image Python de base.

### Changed
- **`.github/workflows/build.yml` : `:X.Y` et `:latest` réservés aux releases stables** — Les tags
pré-release (`v2.1.1-rc.1`, `v2.1.1-dev`, …) ne reçoivent plus d'alias `:X.Y` ni
`:latest`. Un step `Detect stable tag` détecte si le tag Git est de la forme
`vX.Y.Z` (sans tiret) pour activer ces alias. Conforme à la convention SemVer :
un tag avec suffixe = pré-release = ne pas pointer `latest`.

---

## [2.1.2] - 2026-05-11

### 📦 Mises à jour de dépendances (Dependabot)
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ EXPOSE 8002
# Passer en utilisateur non-root
USER mcp

# Healthcheck via /health endpoint (léger, pas de fork Python)
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -sf http://localhost:8002/health -o /dev/null 2>/dev/null
# NOTE: Pas de HEALTHCHECK dans l'image — le port est configurable via MCP_SERVER_PORT.
# La responsabilité du healthcheck appartient à l'orchestrateur (docker-compose.yml,
# Swarm stack, Kubernetes liveness probe) qui connaît le port réel de déploiement.

# Point d'entrée
ENTRYPOINT ["python", "-m", "src.mcp_memory.server"]
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ Service de mémoire persistante basé sur un **graphe de connaissances** pour le

Développé par **[Cloud Temple](https://www.cloud-temple.com)**.

[![CI](https://github.com/Cloud-Temple/graph-memory/actions/workflows/build.yml/badge.svg)](https://github.com/Cloud-Temple/graph-memory/actions/workflows/build.yml)
[![Docker](https://img.shields.io/badge/ghcr.io-cloud--temple%2Fgraph--memory-blue?logo=docker)](https://ghcr.io/cloud-temple/graph-memory)
[![Version](https://img.shields.io/badge/version-2.1.1-blue.svg)]()
[![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)]()
[![MCP](https://img.shields.io/badge/protocol-MCP-purple.svg)]()
[![Python](https://img.shields.io/badge/python-3.11+-yellow.svg)]()

<p align="center">
<img src="screenshoot/screen1.png" alt="Graph Memory — Visualisation du graphe de connaissances" width="800">
</p>
Expand Down Expand Up @@ -275,6 +282,7 @@ cp .env.example .env
| `RAG_CHUNK_LIMIT` | `8` | Nombre max de chunks retournés par Qdrant |
| `CHUNK_SIZE` | `500` | Taille cible en tokens par chunk |
| `CHUNK_OVERLAP` | `50` | Tokens de chevauchement entre chunks |
| `PROXY_URL` | _(aucun)_ | Proxy HTTP sortant pour S3, LLM et embeddings (ex: `http://10.185.132.250:3128`). Variable custom — pas `HTTP_PROXY` — injectée manuellement pour ne pas forcer le proxy sur toutes les libs Python. Non supporté pour Neo4j et Qdrant. |

Voir `.env.example` pour la liste complète.

Expand Down
8 changes: 5 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ services:
# Server config
- MCP_SERVER_DEBUG=${MCP_SERVER_DEBUG:-false}
- ADMIN_BOOTSTRAP_KEY=${ADMIN_BOOTSTRAP_KEY}
# Proxy HTTP sortant (optionnel — ne pas définir si absent)
# - PROXY_URL=${PROXY_URL:-}
healthcheck:
# /health est un endpoint léger qui retourne le status du service + version.
# Avec Streamable HTTP (v1.4.0+), l'ancien endpoint /sse n'existe plus.
test: ["CMD-SHELL", "curl -sf http://localhost:8002/health -o /dev/null 2>/dev/null"]
# python est toujours disponible dans l'image — pas de dépendance à curl.
# Adapter le port si MCP_SERVER_PORT est surchargé.
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8002/health', timeout=5)\" 2>/dev/null"]
interval: 30s
timeout: 10s
start_period: 10s
Expand Down
22 changes: 21 additions & 1 deletion src/mcp_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from functools import lru_cache
from typing import Optional
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand Down Expand Up @@ -105,7 +106,26 @@ class Settings(BaseSettings):
extraction_timeout_seconds: int = 600 # 10 min par appel LLM (gros docs avec chain-of-thought)
s3_upload_timeout_seconds: int = 60
neo4j_query_timeout_seconds: int = 30


# =========================================================================
# Proxy HTTP sortant (optionnel)
# =========================================================================
# Variable CUSTOM — pas HTTP_PROXY/HTTPS_PROXY — pour ne pas forcer le proxy
# sur toutes les libs Python (boto3, httpx, requests…).
# Injecté manuellement dans boto3 (S3), httpx (LLM extraction + embeddings).
# Non supporté pour Neo4j et Qdrant (clients sans paramètre proxy explicite).
proxy_url: str = ""

@model_validator(mode="after")
def _validate_proxy_url(self) -> "Settings":
"""Vérifie que PROXY_URL est une URL valide si renseignée."""
if self.proxy_url and not self.proxy_url.startswith(("http://", "https://")):
raise ValueError(
f"PROXY_URL must start with http:// or https://, "
f"got '{self.proxy_url[:50]}'"
)
return self

@property
def llmaas_base_url(self) -> str:
"""URL complète pour le client OpenAI (compatible OpenAI)."""
Expand Down
22 changes: 19 additions & 3 deletions src/mcp_memory/core/embedder.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import sys
from typing import Optional, List

import httpx
from openai import AsyncOpenAI
from openai import APIError, APITimeoutError
from tenacity import retry, stop_after_attempt, wait_exponential
Expand All @@ -29,16 +30,31 @@ class EmbeddingService:
"""

def __init__(self):
"""Initialise le client OpenAI pour les embeddings."""
"""Initialise le client OpenAI pour les embeddings.

Si PROXY_URL est définie, les appels d'embedding transitent par ce proxy
via un httpx.AsyncClient dédié. On n'utilise pas HTTP_PROXY/HTTPS_PROXY
pour ne pas affecter les autres libs Python.
"""
settings = get_settings()


proxy_url = settings.proxy_url.strip() or None
_http_client = (
httpx.AsyncClient(proxy=httpx.Proxy(proxy_url))
if proxy_url
else None
)

# Utilise le même client OpenAI que l'extracteur
# L'API LLMaaS Cloud Temple est compatible OpenAI
self._client = AsyncOpenAI(
base_url=settings.llmaas_base_url,
api_key=settings.llmaas_api_key,
timeout=60.0
timeout=60.0,
**({"http_client": _http_client} if _http_client else {}),
)
if proxy_url:
print(f"[EmbeddingService] embedding requests via proxy {proxy_url}", file=sys.stderr)
self._model = settings.llmaas_embedding_model
self._dimensions = settings.llmaas_embedding_dimensions

Expand Down
22 changes: 19 additions & 3 deletions src/mcp_memory/core/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Optional, List
from tenacity import retry, stop_after_attempt, wait_exponential

import httpx
from openai import AsyncOpenAI
from openai import APIError, APITimeoutError

Expand Down Expand Up @@ -66,14 +67,29 @@ class ExtractorService:
"""

def __init__(self):
"""Initialise le client OpenAI compatible."""
"""Initialise le client OpenAI compatible.

Si PROXY_URL est définie, les appels LLM transitent par ce proxy
via un httpx.AsyncClient dédié. On n'utilise pas HTTP_PROXY/HTTPS_PROXY
pour ne pas affecter les autres libs Python.
"""
settings = get_settings()


proxy_url = settings.proxy_url.strip() or None
_http_client = (
httpx.AsyncClient(proxy=httpx.Proxy(proxy_url))
if proxy_url
else None
)

self._client = AsyncOpenAI(
base_url=settings.llmaas_base_url,
api_key=settings.llmaas_api_key,
timeout=settings.extraction_timeout_seconds
timeout=settings.extraction_timeout_seconds,
**({"http_client": _http_client} if _http_client else {}),
)
if proxy_url:
print(f"[ExtractorService] LLM requests via proxy {proxy_url}", file=sys.stderr)
self._model = settings.llmaas_model
self._max_tokens = settings.llmaas_max_tokens
self._temperature = settings.llmaas_temperature
Expand Down
25 changes: 20 additions & 5 deletions src/mcp_memory/core/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,23 @@ def __init__(self):
# Région Dell ECS Cloud Temple
region = settings.s3_region_name if settings.s3_region_name else "fr1"

# Proxy HTTP sortant — utilise PROXY_URL (variable custom) plutôt que
# HTTP_PROXY/HTTPS_PROXY pour ne pas affecter les autres libs Python.
proxy_url = settings.proxy_url.strip() or None
_proxies: dict | None = (
{"http": proxy_url, "https": proxy_url} if proxy_url else None
)

# Client SigV2 pour PUT/GET/DELETE (opérations sur objets)
# Tests validés: PUT ✅, GET ✅, DELETE ✅
config_v2 = Config(
region_name=region,
signature_version='s3', # SigV2 legacy
s3={'addressing_style': 'path'},
retries={'max_attempts': 3, 'mode': 'adaptive'}
retries={'max_attempts': 3, 'mode': 'adaptive'},
**({"proxies": _proxies} if _proxies else {}),
)

self._client_v2 = boto3.client(
's3',
endpoint_url=settings.s3_endpoint_url,
Expand All @@ -57,16 +65,17 @@ def __init__(self):
region_name=region,
config=config_v2
)

# Client SigV4 pour HEAD/LIST (opérations métadonnées)
# Utilisé en fallback si SigV2 échoue sur ces opérations
config_v4 = Config(
region_name=region,
signature_version='s3v4',
s3={'addressing_style': 'path', 'payload_signing_enabled': False},
retries={'max_attempts': 3, 'mode': 'adaptive'}
retries={'max_attempts': 3, 'mode': 'adaptive'},
**({"proxies": _proxies} if _proxies else {}),
)

self._client_v4 = boto3.client(
's3',
endpoint_url=settings.s3_endpoint_url,
Expand All @@ -75,6 +84,12 @@ def __init__(self):
region_name=region,
config=config_v4
)

if proxy_url:
print(
f"[StorageService] S3 requests via proxy {proxy_url}",
file=__import__("sys").stderr,
)

# Client par défaut (SigV2 pour compatibilité maximale)
self._client = self._client_v2
Expand Down
Loading