From f7b5708923b2eb064e8920bd6206cffcc7697966 Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:10:13 +0200 Subject: [PATCH 01/10] feat: update environment configuration and remove legacy MemPalace references - Added new environment variables for optimal tool formatting and web tuning in `.env.example`. - Enabled Harness v2 features by default for new installations. - Removed deprecated MemPalace navigation keys and updated related scripts to use Mnemos. - Enhanced the setup scripts to reflect the changes in environment variable management. --- .env.example | 86 +++--- scripts/apply_optimal_aion_env.py | 178 ++++++++++++ scripts/env_tuning_profiles.py | 310 +++++++++++++++++++++ scripts/migrate_mempalace_to_mnemos_env.py | 138 +++++++++ scripts/setup_aion_env.py | 12 + scripts/setup_core.py | 9 +- scripts/upgrade_core.py | 192 +++++++++---- src/test/test_env_tuning_profiles.py | 70 +++++ 8 files changed, 903 insertions(+), 92 deletions(-) create mode 100755 scripts/apply_optimal_aion_env.py create mode 100644 scripts/env_tuning_profiles.py create mode 100755 scripts/migrate_mempalace_to_mnemos_env.py create mode 100644 src/test/test_env_tuning_profiles.py diff --git a/.env.example b/.env.example index fc51767b..dee8cc08 100644 --- a/.env.example +++ b/.env.example @@ -296,7 +296,8 @@ See also `datasource_memory_protocol` for Mnemos note format. AION_LTM_WAKE_MAX_ROWS=20 AION_MNEMOS_RECALL_LIMIT=10 AION_MNEMOS_NATIVE_TOOLS=1 -# Ranking fusion: RRF + recency/importance boosts +AION_MNEMOS_READONLY_TOOLS=0 +AION_MNEMOS_RECALL_LIMIT_EXPOSED=1 AION_MNEMOS_RANK_HALF_LIFE_DAYS=90 AION_MNEMOS_RANK_W_RECENCY=0.3 AION_MNEMOS_RANK_W_IMPORTANCE=0.2 @@ -542,16 +543,29 @@ AION_WEB_FETCH_MAX_CHARS=24000 # PDF evidence crop (MCP pdf_evidence_crop on ocr server) — Word report screenshots # AION_PDF_EVIDENCE_DPI=200 # AION_PDF_EVIDENCE_MAX_WHITE_RATIO=0.90 -# Con AION_TOOL_OFFLOAD_ENABLED=1: cap estrazione pagina su disco (default ~min(bytes/2, 250k)) -# AION_WEB_FETCH_OFFLOAD_MAX_CHARS=200000 # Formato risultati tool web nel contesto LLM: toon (default, -30/60% token) | json -# AION_TOOL_RESULT_FORMAT=toon +AION_TOOL_RESULT_FORMAT=toon # Regex su URL completo; vuoto = nessun filtro (usa solo in ambienti controllati) AION_WEB_FETCH_ALLOWLIST_REGEX= # 1 = tenta StealthyFetcher Scrapling dopo Fetcher/httpx (richiede scrapling[fetchers] + browser) AION_SCRAPLING_STEALTH_ENABLED=0 # Profondità Tavily: basic | advanced | fast | ultra-fast AION_TAVILY_SEARCH_DEPTH=basic +# Cap tool result in agent context (NOT page extract — see AION_WEB_FETCH_MAX_CHARS) +AION_TOOL_WEB_FETCH_MAX_CHARS=48000 +AION_TOOL_WEB_SEARCH_MAX_CHARS=12000 +# Snippet length in TOON web_search rows (single-line; default 1200) +AION_TOON_WEB_SEARCH_SNIPPET_CHARS=1200 +# Compact anticipato dopo N web_search/web_fetch (prima del 92% globale) +AION_WEB_TOOL_COMPACT_AFTER=4 +# Con AION_TOOL_OFFLOAD_ENABLED=1: cap estrazione pagina su disco +AION_WEB_FETCH_OFFLOAD_MAX_CHARS=200000 +AION_TOOL_CIRCUIT_BREAKER_ENABLED=1 +AION_WIKIPEDIA_EXTRACT_FALLBACK_MIN_CHARS=2000 +AION_WIKIPEDIA_FETCH_MODE=article +AION_CHROMA_SHARED_EMBEDDING_CACHE=1 +# Set by migrate_mempalace_to_mnemos_env.py after legacy MemPalace removal +# AION_MEMORY_STACK=mnemos # ----------------------------------------------------------------------------- # API client URLs (chat-ui SSR, upload, download) @@ -720,13 +734,13 @@ AION_TOOL_RESULT_MAX_CHARS=24000 # AION_TURN_DIAGNOSTICS_LOG=data/diagnostics/turns.jsonl # AION_AGENT_DEBUG_LOG=data/diagnostics/agent-debug.jsonl -# Harness v2 (Pi-inspired patterns) — tutti default off per rollout graduale -# AION_HARNESS_V2_MESSAGES=1 -# AION_HARNESS_V2_INJECTIONS=1 -# AION_HARNESS_V2_COMPACTION=1 -# AION_HARNESS_V2_PROVIDER=1 -# AION_HARNESS_V2_TOOLS=1 -# AION_HARNESS_V2_TURN=1 +# Harness v2 (Pi-inspired patterns) — default on for new installs +AION_HARNESS_V2_MESSAGES=1 +AION_HARNESS_V2_INJECTIONS=1 +AION_HARNESS_V2_COMPACTION=1 +AION_HARNESS_V2_PROVIDER=1 +AION_HARNESS_V2_TOOLS=1 +AION_HARNESS_V2_TURN=1 # Long Run mode (Pi agent runtime via services/pi-long-run worker) # AION_LONG_RUN_ENABLED=0 @@ -984,43 +998,43 @@ AION_AGENT_DB_EMBED_SECRET= # ----------------------------------------------------------------------------- # Optimizer (dataset di valutazione) — src/optimizer/ # ----------------------------------------------------------------------------- -# --- Benchmark harness (eval + LongMemEval-V2) --- +# --- Benchmark harness (eval + LongMemEval-V2) — not written to prod .env by setup --- AION_BENCHMARK_DATA_DIR=data/benchmarks AION_BENCHMARK_VERBOSE=1 AION_BENCHMARK_SUBPROCESS_PYTHON=python -AION_LME_V2_TIER=small -AION_LME_V2_TEXT_ONLY=1 -AION_LME_V2_JUDGE_PROFILE=aion_std -AION_LME_V2_DOWNLOAD_SCREENSHOTS=0 -AION_LME_V2_MAX_TRAJECTORIES=100 +# AION_LME_V2_TIER=small +# AION_LME_V2_TEXT_ONLY=1 +# AION_LME_V2_JUDGE_PROFILE=aion_std +# AION_LME_V2_DOWNLOAD_SCREENSHOTS=0 +# AION_LME_V2_MAX_TRAJECTORIES=100 # 0 = unlimited (full retention — benchmark default via apply_benchmark_isolation_env) -AION_LME_V2_MAX_STATES_PER_TRAJ=0 -AION_LME_V2_MAX_TREE_CHARS=0 -AION_LME_V2_MAX_CHUNKS_PER_TRAJ=0 -AION_LME_V2_BOILERPLATE_THRESHOLD=0.6 -AION_LME_V2_INGEST_BATCH_SIZE=500 -AION_LME_V2_COMPRESS_SCOPE=0 -AION_LME_V2_AGENT_PROFILE=benchmark_memory -AION_LME_V2_SKIP_IMAGE_QUESTIONS=1 +# AION_LME_V2_MAX_STATES_PER_TRAJ=0 +# AION_LME_V2_MAX_TREE_CHARS=0 +# AION_LME_V2_MAX_CHUNKS_PER_TRAJ=0 +# AION_LME_V2_BOILERPLATE_THRESHOLD=0.6 +# AION_LME_V2_INGEST_BATCH_SIZE=500 +# AION_LME_V2_COMPRESS_SCOPE=0 +# AION_LME_V2_AGENT_PROFILE=benchmark_memory +# AION_LME_V2_SKIP_IMAGE_QUESTIONS=1 # Max non-menuitem UI labels per state (menuitems are always kept — answers hide in dropdowns) -AION_LME_V2_UI_LABEL_LIMIT=80 +# AION_LME_V2_UI_LABEL_LIMIT=80 # Max chars per Mnemos note at ingest (Mnemos CONTENT_MAX_CHARS is 500) -AION_LME_V2_NOTE_MAX_CHARS=480 +# AION_LME_V2_NOTE_MAX_CHARS=480 # FTS recall rows merged per benchmark question (boosted query + full text) -AION_LME_V2_RECALL_LIMIT=20 +# AION_LME_V2_RECALL_LIMIT=20 # Skip Mnemos wake block during QA (reduces noise from unrelated early trajectories) -AION_LME_V2_SKIP_WAKE=1 -AION_LME_V2_QA_MAX_TOKENS=512 -AION_LME_V2_QA_TIMEOUT=120 +# AION_LME_V2_SKIP_WAKE=1 +# AION_LME_V2_QA_MAX_TOKENS=512 +# AION_LME_V2_QA_TIMEOUT=120 # Qwen3/vLLM: MUST stay 1 for benchmark judge (0 => thinking on => content=null) -AION_LME_V2_QA_DISABLE_REASONING=1 +# AION_LME_V2_QA_DISABLE_REASONING=1 # Official scoring: 156/451 questions carry eval_function=llm_*_checker and need # an LLM grader. Set 0 to score them 0.0 without any LLM call (offline runs). -AION_LME_V2_LLM_JUDGE=1 -AION_LME_V2_JUDGE_MAX_TOKENS=16 -AION_LME_V2_JUDGE_TIMEOUT=60 +# AION_LME_V2_LLM_JUDGE=1 +# AION_LME_V2_JUDGE_MAX_TOKENS=16 +# AION_LME_V2_JUDGE_TIMEOUT=60 # Benchmark agent + Mnemos (set automatically by apply_benchmark_isolation_env) -AION_MNEMOS_READONLY_TOOLS=1 +# AION_MNEMOS_READONLY_TOOLS=1 AION_MNEMOS_RECALL_LIMIT_EXPOSED=1 AION_OPTUNA_STORAGE=sqlite:///data/optuna.db diff --git a/scripts/apply_optimal_aion_env.py b/scripts/apply_optimal_aion_env.py new file mode 100755 index 00000000..477dd0c1 --- /dev/null +++ b/scripts/apply_optimal_aion_env.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Apply optimal AION runtime tuning to an existing .env (optional, idempotent-ish). + +Touches harness v2, TOON tool format, context compression, tool offload, web tools. +Never overwrites sandbox/podman/URL/secrets (see env_tuning_profiles.PROTECTED_*). + +Usage: + python scripts/apply_optimal_aion_env.py --dry-run + python scripts/apply_optimal_aion_env.py --force -y + python scripts/apply_optimal_aion_env.py --only harness,compression,offload +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.env_tuning_profiles import ( # noqa: E402 + BENCHMARK_ONLY_ENV_KEYS, + OPTIMAL_RUNTIME_TUNING, + apply_env_profile, + backup_env_file, + env_key_map, + is_protected_env_key, + merge_missing_only, +) + +TUNING_GROUPS: dict[str, frozenset[str]] = { + "harness": frozenset(k for k in OPTIMAL_RUNTIME_TUNING if k.startswith("AION_HARNESS_V2_")), + "toon": frozenset( + { + "AION_TOOL_RESULT_FORMAT", + "AION_TOOL_WEB_FETCH_MAX_CHARS", + "AION_TOOL_WEB_SEARCH_MAX_CHARS", + "AION_TOON_WEB_SEARCH_SNIPPET_CHARS", + } + ), + "offload": frozenset( + k + for k in OPTIMAL_RUNTIME_TUNING + if k.startswith("AION_TOOL_OFFLOAD_") or k.startswith("AION_TOOL_LEDGER_") + ), + "compression": frozenset( + k for k in OPTIMAL_RUNTIME_TUNING if k.startswith("AION_CONTEXT_COMPRESS_") + or k in {"AION_MODEL_MAX_CONTEXT"} + ), + "web": frozenset( + k + for k in OPTIMAL_RUNTIME_TUNING + if k.startswith("AION_WEB_") or k.startswith("AION_WIKIPEDIA_") + ), + "tools": frozenset( + { + "AION_TOOL_CIRCUIT_BREAKER_ENABLED", + "AION_TOOL_RESULT_MAX_CHARS", + "AION_CHROMA_SHARED_EMBEDDING_CACHE", + } + ), +} + + +def _select_keys(groups: str | None) -> dict[str, str]: + if not groups: + return dict(OPTIMAL_RUNTIME_TUNING) + names = [g.strip().lower() for g in groups.split(",") if g.strip()] + unknown = [n for n in names if n not in TUNING_GROUPS] + if unknown: + valid = ", ".join(sorted(TUNING_GROUPS)) + raise SystemExit(f"Unknown group(s): {', '.join(unknown)}. Valid: {valid}") + keys: set[str] = set() + for name in names: + keys |= set(TUNING_GROUPS[name]) + return {k: v for k, v in OPTIMAL_RUNTIME_TUNING.items() if k in keys} + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Apply optimal AION runtime tuning keys to .env" + ) + parser.add_argument("--env", default=str(ROOT / ".env")) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing values (still skips protected keys)", + ) + parser.add_argument( + "--only", + metavar="GROUPS", + help="Comma-separated: harness,toon,offload,compression,web,tools", + ) + parser.add_argument( + "--prune-benchmark-keys", + action="store_true", + help="Remove AION_LME_V2_* benchmark keys from .env", + ) + parser.add_argument("-y", "--yes", action="store_true") + args = parser.parse_args() + + env_path = Path(args.env).resolve() + if not env_path.is_file(): + print(f"[error] .env not found: {env_path}", file=sys.stderr) + return 2 + + try: + profile = _select_keys(args.only) + except SystemExit as exc: + print(f"[error] {exc}", file=sys.stderr) + return 2 + + before = env_key_map(env_path) + if args.force: + to_set = { + k: v + for k, v in profile.items() + if not is_protected_env_key(k) + } + else: + to_set = merge_missing_only(env_path, profile) + + remove_keys: list[str] = [] + if args.prune_benchmark_keys: + remove_keys = sorted(k for k in BENCHMARK_ONLY_ENV_KEYS if k in before) + + if not to_set and not remove_keys: + print("[ok] All selected tuning keys already present; nothing to do.") + return 0 + + print(f"Tuning: {len(to_set)} key(s) to set, {len(remove_keys)} to remove.") + if to_set and not args.force: + print(" (missing keys only — use --force to overwrite existing values)") + + changes_preview = [] + for k, v in sorted(to_set.items()): + old = before.get(k) + if old is not None and old != v: + changes_preview.append(f" {k}: {old!r} → {v!r}") + elif old is None: + changes_preview.append(f" {k}: (new) = {v!r}") + for line in changes_preview[:20]: + print(line) + if len(changes_preview) > 20: + print(f" ... and {len(changes_preview) - 20} more") + + if not args.dry_run and not args.yes and (to_set or remove_keys): + if not sys.stdin.isatty(): + print("[error] Non-interactive: pass -y to confirm.", file=sys.stderr) + return 1 + ans = input("Apply optimal tuning? [y/N] ").strip().lower() + if ans not in ("y", "yes"): + print("Aborted.") + return 1 + + if not args.dry_run: + backup = backup_env_file(env_path) + print(f"[backup] {backup}") + + result = apply_env_profile( + env_path, + set_values=to_set, + remove_keys=remove_keys, + dry_run=args.dry_run, + skip_protected=True, + ) + + mode = "would apply" if args.dry_run else "applied" + print(f"[ok] Tuning {mode}: set {len(result['applied'])}, removed {len(result['removed'])}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/env_tuning_profiles.py b/scripts/env_tuning_profiles.py new file mode 100644 index 00000000..82bf8fa3 --- /dev/null +++ b/scripts/env_tuning_profiles.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Shared optimal AION .env profiles, MemPalace detection, and apply helpers.""" + +from __future__ import annotations + +import shutil +from datetime import datetime +from pathlib import Path +from typing import Iterable + +ROOT = Path(__file__).resolve().parents[1] + +# Marker written by migrate_mempalace_to_mnemos_env.py after a successful migration. +MNEMOS_MIGRATION_MARKER = "AION_MEMORY_STACK" + +MEMPALACE_ENV_KEYS: frozenset[str] = frozenset( + { + "AION_MEMPALACE_DEDUP_THRESHOLD", + "AION_MEMPALACE_NAV_AUTO_KG", + "AION_MEMPALACE_NAV_AUTO_LEARN", + "AION_MEMPALACE_NAV_ENABLED", + "AION_MEMPALACE_NAV_INJECT_THRESHOLD", + "AION_MEMPALACE_NAV_PRE_TURN_INJECT", + "AION_MEMPALACE_NAV_SEARCH_LIMIT", + "AION_MEMPALACE_NAV_SKIP_WHEN_SQL_INJECT", + "AION_MEMPALACE_PROJECT_WING_PREFIX", + "AION_MEMPALACE_WARMUP", + "AION_MEMPALACE_WEAK_MEMORY_THRESHOLD", + } +) + +# Benchmark-only keys — safe to drop from production .env (defaults live in code). +BENCHMARK_ONLY_ENV_KEYS: frozenset[str] = frozenset( + { + "AION_LME_V2_AGENT_PROFILE", + "AION_LME_V2_BOILERPLATE_THRESHOLD", + "AION_LME_V2_COMPRESS_SCOPE", + "AION_LME_V2_DOWNLOAD_SCREENSHOTS", + "AION_LME_V2_INGEST_BATCH_SIZE", + "AION_LME_V2_JUDGE_MAX_TOKENS", + "AION_LME_V2_JUDGE_PROFILE", + "AION_LME_V2_JUDGE_TIMEOUT", + "AION_LME_V2_LLM_JUDGE", + "AION_LME_V2_MAX_CHUNKS_PER_TRAJ", + "AION_LME_V2_MAX_STATES_PER_TRAJ", + "AION_LME_V2_MAX_TRAJECTORIES", + "AION_LME_V2_MAX_TREE_CHARS", + "AION_LME_V2_NOTE_MAX_CHARS", + "AION_LME_V2_QA_DISABLE_REASONING", + "AION_LME_V2_QA_MAX_TOKENS", + "AION_LME_V2_QA_TIMEOUT", + "AION_LME_V2_RECALL_LIMIT", + "AION_LME_V2_SKIP_IMAGE_QUESTIONS", + "AION_LME_V2_SKIP_WAKE", + "AION_LME_V2_TEXT_ONLY", + "AION_LME_V2_TIER", + "AION_LME_V2_UI_LABEL_LIMIT", + } +) + +# Full Mnemos LTM profile (aligned with .env.example production section). +MNEMOS_OPTIMAL: dict[str, str] = { + "AION_LTM_WAKE_MAX_ROWS": "20", + "AION_MNEMOS_RECALL_LIMIT": "10", + "AION_MNEMOS_NATIVE_TOOLS": "1", + "AION_MNEMOS_READONLY_TOOLS": "0", + "AION_MNEMOS_RECALL_LIMIT_EXPOSED": "1", + "AION_MNEMOS_EMBEDDING_RECALL": "1", + "AION_MNEMOS_EMBED_ON_BULK": "1", + "AION_MNEMOS_EMBEDDING_MIN_SCORE": "0.25", + "AION_MNEMOS_EMBEDDING_SCAN_LIMIT": "300", + "AION_MNEMOS_HYBRID_CANDIDATE_MULT": "3", + "AION_MNEMOS_RANK_HALF_LIFE_DAYS": "90", + "AION_MNEMOS_RANK_W_RECENCY": "0.3", + "AION_MNEMOS_RANK_W_IMPORTANCE": "0.2", + "AION_MNEMOS_DREAM_ENABLED": "1", + "AION_MNEMOS_DREAM_HOUR": "3", + "AION_MNEMOS_DREAM_INTERVAL_SEC": "86400", + MNEMOS_MIGRATION_MARKER: "mnemos", +} + +# Runtime tuning: harness v2, TOON tool results, offload, context compression, web tools. +OPTIMAL_RUNTIME_TUNING: dict[str, str] = { + "AION_HARNESS_V2_MESSAGES": "1", + "AION_HARNESS_V2_INJECTIONS": "1", + "AION_HARNESS_V2_COMPACTION": "1", + "AION_HARNESS_V2_PROVIDER": "1", + "AION_HARNESS_V2_TOOLS": "1", + "AION_HARNESS_V2_TURN": "1", + "AION_TOOL_RESULT_FORMAT": "toon", + "AION_TOOL_WEB_FETCH_MAX_CHARS": "48000", + "AION_TOOL_WEB_SEARCH_MAX_CHARS": "12000", + "AION_TOON_WEB_SEARCH_SNIPPET_CHARS": "1200", + "AION_WEB_FETCH_OFFLOAD_MAX_CHARS": "200000", + "AION_WEB_TOOL_COMPACT_AFTER": "4", + "AION_TOOL_CIRCUIT_BREAKER_ENABLED": "1", + "AION_WIKIPEDIA_EXTRACT_FALLBACK_MIN_CHARS": "2000", + "AION_WIKIPEDIA_FETCH_MODE": "article", + "AION_CHROMA_SHARED_EMBEDDING_CACHE": "1", + "AION_MODEL_MAX_CONTEXT": "131072", + "AION_CONTEXT_COMPRESS_ENABLED": "1", + "AION_CONTEXT_COMPRESS_THRESHOLD": "0.80", + "AION_CONTEXT_COMPRESS_MODEL_WINDOW": "131072", + "AION_CONTEXT_COMPRESS_KEEP_LAST": "12", + "AION_CONTEXT_COMPRESS_MAX_ROUNDS": "2", + "AION_CONTEXT_COMPRESS_RESERVE_OUTPUT": "1", + "AION_CONTEXT_COMPRESS_FIXED_OVERHEAD": "8192", + "AION_CONTEXT_COMPRESS_MID_TURN": "1", + "AION_CONTEXT_COMPRESS_MID_TURN_RATIO": "0.92", + "AION_CONTEXT_COMPRESS_MID_TURN_MIN_SEC": "15", + "AION_CONTEXT_COMPRESS_SUMMARY_MAX_TOKENS": "4096", + "AION_WEB_FETCH_MAX_CHARS": "24000", + "AION_TOOL_OFFLOAD_ENABLED": "1", + "AION_TOOL_OFFLOAD_MIN_CHARS": "8000", + "AION_TOOL_OFFLOAD_PREVIEW_CHARS": "1500", + "AION_TOOL_OFFLOAD_EXCLUDE": "web_search,sandbox_read_file_chunk", + "AION_TOOL_OFFLOAD_MAX_TOTAL_MB": "64", + "AION_TOOL_LEDGER_ENABLED": "1", + "AION_TOOL_LEDGER_MAX_ROWS": "60", + "AION_TOOL_LEDGER_MAX_CHARS": "3000", + "AION_TOOL_RESULT_MAX_CHARS": "24000", +} + +# Never overwrite host / container / network identity during tuning or migration. +PROTECTED_ENV_PREFIXES: tuple[str, ...] = ( + "AION_SANDBOX_", + "AION_PODMAN_", + "AION_CONTAINER_", + "NEXT_PUBLIC_", + "COMPOSE_", + "CADDY_", +) + +PROTECTED_ENV_KEYS: frozenset[str] = frozenset( + { + "DOMAIN", + "LETS_ENCRYPT_EMAIL", + "AION_API_URL", + "AION_API_PORT", + "AION_API_HOST", + "AION_FASTAPI_URL", + "AION_PUBLIC_API_URL", + "AION_CHAT_URL", + "AION_ADMIN_UI_URL", + "AION_CORS_ORIGINS", + "AION_REDIS_URL", + "AION_DB_URL", + "AION_DATA_DIR", + "AION_ENV", + "AION_CHAT_AUTH_SECRET", + "AION_CREDENTIAL_ENCRYPTION_KEY", + "AION_TAVILY_API_KEY", + "AION_BRAVE_SEARCH_API_KEY", + "AION_EMBEDDING_URL", + "AION_EMBEDDINGS_API_KEY", + "AION_LLM_API_KEY", + "AION_OCR_BASE_URL", + "AION_MODEL", + } +) + + +def parse_env_simple(path: Path) -> list[tuple[str, str, str]]: + """Return (key, value, original_line) preserving order and comments.""" + out: list[tuple[str, str, str]] = [] + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return out + for raw in text.splitlines(): + s = raw.lstrip() + if not s or s.startswith("#"): + out.append(("", "", raw)) + continue + if "=" not in raw: + out.append(("", "", raw)) + continue + key, _, val = raw.partition("=") + out.append((key.strip(), val.strip(), raw)) + return out + + +def env_key_map(path: Path) -> dict[str, str]: + return {k: v for k, v, _ in parse_env_simple(path) if k} + + +def backup_env_file(env_path: Path) -> Path: + stamp = datetime.now().strftime("%Y%m%d%H%M%S") + backup = env_path.with_suffix(env_path.suffix + f".bak.{stamp}") + shutil.copy2(env_path, backup) + return backup + + +def is_protected_env_key(key: str) -> bool: + if key in PROTECTED_ENV_KEYS: + return True + return any(key.startswith(prefix) for prefix in PROTECTED_ENV_PREFIXES) + + +def mempalace_data_dir(root: Path) -> Path: + return root / "data" / "mempalace" + + +def mnemos_migration_complete(env_path: Path) -> bool: + values = env_key_map(env_path) + return values.get(MNEMOS_MIGRATION_MARKER, "").strip().lower() == "mnemos" + + +def detect_mempalace_legacy(env_path: Path, root: Path | None = None) -> bool: + """True when MemPalace env keys or on-disk data are still present.""" + if mnemos_migration_complete(env_path): + return False + root = root or ROOT + values = env_key_map(env_path) + if any(k in values for k in MEMPALACE_ENV_KEYS): + return True + mp_dir = mempalace_data_dir(root) + if mp_dir.is_dir(): + try: + return any(mp_dir.iterdir()) + except OSError: + return True + return False + + +def _rewrite_env_entries( + entries: list[tuple[str, str, str]], + *, + set_values: dict[str, str], + remove_keys: Iterable[str], +) -> tuple[list[str], dict[str, str]]: + remove = set(remove_keys) + seen: set[str] = set() + out_lines: list[str] = [] + applied: dict[str, str] = {} + removed: set[str] = set() + + for k, _, raw in entries: + if not k: + out_lines.append(raw) + continue + if k in seen: + continue + seen.add(k) + if k in remove: + removed.add(k) + continue + if k in set_values: + out_lines.append(f"{k}={set_values[k]}") + applied[k] = set_values[k] + continue + out_lines.append(raw) + + for key, value in set_values.items(): + if key in seen and key not in applied: + continue + if key in removed: + continue + if key not in seen: + out_lines.append(f"{key}={value}") + applied[key] = value + + return out_lines, applied + + +def apply_env_profile( + env_path: Path, + *, + set_values: dict[str, str], + remove_keys: Iterable[str] = (), + dry_run: bool = False, + skip_protected: bool = True, +) -> dict[str, object]: + """Apply env updates; returns summary dict.""" + if not env_path.is_file(): + raise FileNotFoundError(env_path) + + filtered: dict[str, str] = {} + skipped: list[str] = [] + for key, value in set_values.items(): + if skip_protected and is_protected_env_key(key): + skipped.append(key) + continue + filtered[key] = value + + remove = [k for k in remove_keys if not (skip_protected and is_protected_env_key(k))] + entries = parse_env_simple(env_path) + before = env_key_map(env_path) + out_lines, applied = _rewrite_env_entries( + entries, set_values=filtered, remove_keys=remove + ) + removed = [k for k in remove if k in before] + + if not dry_run: + env_path.write_text("\n".join(out_lines) + "\n", encoding="utf-8") + + return { + "applied": applied, + "removed": removed, + "skipped_protected": skipped, + "dry_run": dry_run, + } + + +def merge_missing_only( + env_path: Path, defaults: dict[str, str] +) -> dict[str, str]: + """Return key→value pairs that are absent from env_path.""" + present = env_key_map(env_path) + return {k: v for k, v in defaults.items() if k not in present} diff --git a/scripts/migrate_mempalace_to_mnemos_env.py b/scripts/migrate_mempalace_to_mnemos_env.py new file mode 100755 index 00000000..90611857 --- /dev/null +++ b/scripts/migrate_mempalace_to_mnemos_env.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Migrate legacy MemPalace navigation env → Mnemos LTM (conditional). + +Runs only when MemPalace is still detected (env keys or data/mempalace/). +Skips when AION_MEMORY_STACK=mnemos is already set. + +Usage: + python scripts/migrate_mempalace_to_mnemos_env.py + python scripts/migrate_mempalace_to_mnemos_env.py --env .env --dry-run + python scripts/migrate_mempalace_to_mnemos_env.py -y +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.env_tuning_profiles import ( # noqa: E402 + BENCHMARK_ONLY_ENV_KEYS, + MNEMOS_OPTIMAL, + MEMPALACE_ENV_KEYS, + ROOT as ENV_ROOT, + apply_env_profile, + backup_env_file, + detect_mempalace_legacy, + merge_missing_only, + mnemos_migration_complete, +) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Remove MemPalace env keys and apply optimal Mnemos profile." + ) + parser.add_argument( + "--env", + default=str(ROOT / ".env"), + help="Path to .env (default: repo root .env)", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument( + "-y", + "--yes", + action="store_true", + help="Skip confirmation prompt", + ) + parser.add_argument( + "--prune-benchmark-keys", + action="store_true", + help="Also remove AION_LME_V2_* keys (benchmark-only clutter in prod)", + ) + parser.add_argument( + "--apply-runtime-tuning", + action="store_true", + help="Also apply OPTIMAL_RUNTIME_TUNING (harness v2, TOON, compression)", + ) + args = parser.parse_args() + + env_path = Path(args.env).resolve() + if not env_path.is_file(): + print(f"[error] .env not found: {env_path}", file=sys.stderr) + return 2 + + if mnemos_migration_complete(env_path): + print("[skip] Mnemos migration already complete (AION_MEMORY_STACK=mnemos).") + return 0 + + if not detect_mempalace_legacy(env_path, ENV_ROOT): + print("[skip] MemPalace not detected — nothing to migrate.") + return 0 + + remove_keys = set(MEMPALACE_ENV_KEYS) + if args.prune_benchmark_keys: + remove_keys |= BENCHMARK_ONLY_ENV_KEYS + + set_values = dict(MNEMOS_OPTIMAL) + if args.apply_runtime_tuning: + from scripts.env_tuning_profiles import OPTIMAL_RUNTIME_TUNING + + set_values.update(OPTIMAL_RUNTIME_TUNING) + + missing = merge_missing_only(env_path, set_values) + print( + f"MemPalace → Mnemos: will set/update {len(set_values)} keys, " + f"remove {len(remove_keys)} keys." + ) + if missing: + print(f" New keys: {len(missing)}") + + if not args.dry_run and not args.yes: + if not sys.stdin.isatty(): + print( + "[error] Non-interactive shell: pass -y to confirm migration.", + file=sys.stderr, + ) + return 1 + ans = input("Proceed with MemPalace → Mnemos migration? [y/N] ").strip().lower() + if ans not in ("y", "yes"): + print("Aborted.") + return 1 + + if not args.dry_run: + backup = backup_env_file(env_path) + print(f"[backup] {backup}") + + result = apply_env_profile( + env_path, + set_values=set_values, + remove_keys=sorted(remove_keys), + dry_run=args.dry_run, + skip_protected=True, + ) + + mode = "would apply" if args.dry_run else "applied" + print(f"[ok] Migration {mode}:") + print(f" set: {len(result['applied'])} keys") + print(f" removed: {len(result['removed'])} keys") + if result["removed"]: + for key in sorted(result["removed"])[:12]: + print(f" - {key}") + if len(result["removed"]) > 12: + print(f" ... and {len(result['removed']) - 12} more") + if not args.dry_run: + print( + "\nRestart the backend after migration. " + "Optional: python scripts/apply_optimal_aion_env.py for full runtime tuning." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/setup_aion_env.py b/scripts/setup_aion_env.py index 4fc016ea..74fe85a7 100644 --- a/scripts/setup_aion_env.py +++ b/scripts/setup_aion_env.py @@ -75,6 +75,18 @@ def _read_version() -> str: "AION_SETUP_CHAINLIT_IDENTIFIER", # → AION_SETUP_CHAT_IDENTIFIER "AION_SETUP_CHAINLIT_PASSWORD", # → AION_SETUP_CHAT_PASSWORD "AION_CRON_DB_PATH", # jobs live in AION_DB_URL (unified DB) + # Legacy MemPalace navigation — migrate with scripts/migrate_mempalace_to_mnemos_env.py + "AION_MEMPALACE_DEDUP_THRESHOLD", + "AION_MEMPALACE_NAV_AUTO_KG", + "AION_MEMPALACE_NAV_AUTO_LEARN", + "AION_MEMPALACE_NAV_ENABLED", + "AION_MEMPALACE_NAV_INJECT_THRESHOLD", + "AION_MEMPALACE_NAV_PRE_TURN_INJECT", + "AION_MEMPALACE_NAV_SEARCH_LIMIT", + "AION_MEMPALACE_NAV_SKIP_WHEN_SQL_INJECT", + "AION_MEMPALACE_PROJECT_WING_PREFIX", + "AION_MEMPALACE_WARMUP", + "AION_MEMPALACE_WEAK_MEMORY_THRESHOLD", } ) diff --git a/scripts/setup_core.py b/scripts/setup_core.py index bfdf643b..9acd2438 100644 --- a/scripts/setup_core.py +++ b/scripts/setup_core.py @@ -220,10 +220,13 @@ def main() -> int: spec.loader.exec_module(up_mod) rep = up_mod.Report() up_mod._ensure_sql_qm_env_keys(out_path, dry_run=False, report=rep) - up_mod._ensure_mempalace_nav_env_keys( + up_mod._ensure_mnemos_env_keys( out_path, dry_run=False, report=rep ) - up_mod._ensure_mnemos_env_keys( + up_mod._ensure_harness_v2_env_keys( + out_path, dry_run=False, report=rep + ) + up_mod._ensure_optimal_tool_format_env_keys( out_path, dry_run=False, report=rep ) up_mod._ensure_skill_view_env_keys( @@ -237,7 +240,7 @@ def main() -> int: ) except Exception as exc: print( - f"[warn] memory env defaults (SQL QM / MemPalace nav): {exc}", + f"[warn] memory env defaults (Mnemos / harness): {exc}", file=sys.stderr, ) diff --git a/scripts/upgrade_core.py b/scripts/upgrade_core.py index b59ed62d..afa29d75 100644 --- a/scripts/upgrade_core.py +++ b/scripts/upgrade_core.py @@ -497,23 +497,12 @@ def _migrate_docker_data_paths_in_env( "AION_SQL_QM_TOOL_TIMEOUT_SEC": "60", } -_MEMPALACE_NAV_ENV_DEFAULTS: dict[str, str] = { - "AION_MEMPALACE_NAV_ENABLED": "1", - "AION_MEMPALACE_NAV_PRE_TURN_INJECT": "0", - "AION_MEMPALACE_NAV_INJECT_THRESHOLD": "0.75", - "AION_MEMPALACE_NAV_AUTO_LEARN": "0", - "AION_MEMPALACE_PROJECT_WING_PREFIX": "wing_proj_", - "AION_MEMPALACE_NAV_SEARCH_LIMIT": "5", - "AION_MEMPALACE_DEDUP_THRESHOLD": "0.87", - "AION_MEMPALACE_WEAK_MEMORY_THRESHOLD": "0.4", - "AION_MEMPALACE_NAV_AUTO_KG": "0", - "AION_LTM_MIN_IMPORTANCE": "2", - "AION_AGENT_MIN_REASONING_CHARS_WITHOUT_TOOL": "2500", - "AION_AGENT_MAX_REASONING_WITHOUT_TOOL": "0", - "AION_SQL_QM_PARAMETERIZE": "1", -} - -_MNEMOS_ENV_DEFAULTS: dict[str, str] = { +# Legacy MemPalace navigation removed — use Mnemos + migrate_mempalace_to_mnemos_env.py + "AION_LTM_WAKE_MAX_ROWS": "20", + "AION_MNEMOS_RECALL_LIMIT": "10", + "AION_MNEMOS_NATIVE_TOOLS": "1", + "AION_MNEMOS_READONLY_TOOLS": "0", + "AION_MNEMOS_RECALL_LIMIT_EXPOSED": "1", "AION_MNEMOS_EMBEDDING_RECALL": "1", "AION_MNEMOS_EMBED_ON_BULK": "1", "AION_MNEMOS_EMBEDDING_MIN_SCORE": "0.25", @@ -523,6 +512,8 @@ def _migrate_docker_data_paths_in_env( "AION_MNEMOS_RANK_W_RECENCY": "0.3", "AION_MNEMOS_RANK_W_IMPORTANCE": "0.2", "AION_MNEMOS_DREAM_ENABLED": "1", + "AION_MNEMOS_DREAM_HOUR": "3", + "AION_MNEMOS_DREAM_INTERVAL_SEC": "86400", } _MCP_POOL_ENV_DEFAULTS: dict[str, str] = { @@ -562,12 +553,38 @@ def _migrate_docker_data_paths_in_env( _CONTEXT_COMPRESS_ENV_DEFAULTS: dict[str, str] = { "AION_MODEL_MAX_CONTEXT": "131072", "AION_CONTEXT_COMPRESS_ENABLED": "1", - "AION_CONTEXT_COMPRESS_THRESHOLD": "0.5", + "AION_CONTEXT_COMPRESS_THRESHOLD": "0.80", "AION_CONTEXT_COMPRESS_MODEL_WINDOW": "131072", - "AION_CONTEXT_COMPRESS_KEEP_LAST": "6", - "AION_CONTEXT_COMPRESS_MAX_ROUNDS": "3", + "AION_CONTEXT_COMPRESS_KEEP_LAST": "12", + "AION_CONTEXT_COMPRESS_MAX_ROUNDS": "2", "AION_CONTEXT_COMPRESS_RESERVE_OUTPUT": "1", - "AION_CONTEXT_COMPRESS_FIXED_OVERHEAD": "4096", + "AION_CONTEXT_COMPRESS_FIXED_OVERHEAD": "8192", + "AION_CONTEXT_COMPRESS_MID_TURN": "1", + "AION_CONTEXT_COMPRESS_MID_TURN_RATIO": "0.92", + "AION_CONTEXT_COMPRESS_MID_TURN_MIN_SEC": "15", + "AION_CONTEXT_COMPRESS_SUMMARY_MAX_TOKENS": "4096", +} + +_HARNESS_V2_ENV_DEFAULTS: dict[str, str] = { + "AION_HARNESS_V2_MESSAGES": "1", + "AION_HARNESS_V2_INJECTIONS": "1", + "AION_HARNESS_V2_COMPACTION": "1", + "AION_HARNESS_V2_PROVIDER": "1", + "AION_HARNESS_V2_TOOLS": "1", + "AION_HARNESS_V2_TURN": "1", +} + +_OPTIMAL_TOOL_FORMAT_ENV_DEFAULTS: dict[str, str] = { + "AION_TOOL_RESULT_FORMAT": "toon", + "AION_TOOL_WEB_FETCH_MAX_CHARS": "48000", + "AION_TOOL_WEB_SEARCH_MAX_CHARS": "12000", + "AION_TOON_WEB_SEARCH_SNIPPET_CHARS": "1200", + "AION_WEB_FETCH_OFFLOAD_MAX_CHARS": "200000", + "AION_WEB_TOOL_COMPACT_AFTER": "4", + "AION_TOOL_CIRCUIT_BREAKER_ENABLED": "1", + "AION_WIKIPEDIA_EXTRACT_FALLBACK_MIN_CHARS": "2000", + "AION_WIKIPEDIA_FETCH_MODE": "article", + "AION_CHROMA_SHARED_EMBEDDING_CACHE": "1", } _AGENT_MODE_ENV_DEFAULTS = { @@ -775,36 +792,10 @@ def _patch_sql_query_memory_config(py_exec: str, dry_run: bool, report: Report) def _ensure_mempalace_nav_env_keys( env_path: Path, *, dry_run: bool, report: Report ) -> int: - if not env_path.is_file(): - report.log_ok("MemPalace navigation env defaults: .env assente, skip") - return 0 - entries = _parse_env_simple(env_path) - keys_file = {k for k, _, _ in entries if k} - missing = [ - (k, v) for k, v in _MEMPALACE_NAV_ENV_DEFAULTS.items() if k not in keys_file - ] - if not missing: - report.log_ok("MemPalace navigation env defaults: chiavi già presenti") - return 0 - if dry_run: - report.log_ok( - f"MemPalace navigation env defaults: aggiungerebbe {len(missing)} chiavi (dry-run)" - ) - return 0 - block = ( - "\n# --- MemPalace navigazione ERP (append da upgrade-aion): wing_proj_{project} allineato a SQL QM ---\n" - + "\n".join(f"{k}={v}" for k, v in missing) - + "\n" + """Deprecated — MemPalace env keys are no longer added (use Mnemos).""" + report.log_ok( + "MemPalace navigation env: skip (deprecated — scripts/migrate_mempalace_to_mnemos_env.py)" ) - try: - env_path.write_text( - env_path.read_text(encoding="utf-8").rstrip() + "\n" + block, - encoding="utf-8", - ) - except Exception as e: - report.log_fail(f"MemPalace navigation env defaults: scrittura fallita: {e}") - return 3 - report.log_ok(f"MemPalace navigation env defaults: aggiunte {len(missing)} chiavi") return 0 @@ -992,8 +983,19 @@ def _ensure_skill_view_env_keys( def _patch_mempalace_navigation_config( - py_exec: str, dry_run: bool, report: Report + py_exec: str, dry_run: bool, report: Report, *, env_file: str | Path = ".env" ) -> None: + env_path = Path(env_file) + if not env_path.is_absolute(): + env_path = ROOT / env_path + try: + from scripts.env_tuning_profiles import detect_mempalace_legacy + + if not detect_mempalace_legacy(env_path, ROOT): + report.log_ok("MemPalace navigation config patch: skip (Mnemos / no legacy)") + return + except ImportError: + pass patch_script = ROOT / "scripts" / "patch_mempalace_navigation_config.py" if not patch_script.is_file(): report.log_warn( @@ -1046,6 +1048,66 @@ def _ensure_context_compress_env_keys( return 0 +def _ensure_env_defaults_from_map( + env_path: Path, + *, + defaults: dict[str, str], + block_title: str, + dry_run: bool, + report: Report, + ok_label: str, +) -> int: + if not env_path.is_file(): + report.log_ok(f"{ok_label}: .env assente, skip") + return 0 + entries = _parse_env_simple(env_path) + keys_file = {k for k, _, _ in entries if k} + missing = [(k, v) for k, v in defaults.items() if k not in keys_file] + if not missing: + report.log_ok(f"{ok_label}: chiavi già presenti") + return 0 + if dry_run: + report.log_ok(f"{ok_label}: aggiungerebbe {len(missing)} chiavi (dry-run)") + return 0 + block = f"\n# --- {block_title} ---\n" + "\n".join( + f"{k}={v}" for k, v in missing + ) + "\n" + try: + env_path.write_text( + env_path.read_text(encoding="utf-8").rstrip() + "\n" + block, + encoding="utf-8", + ) + except Exception as e: + report.log_fail(f"{ok_label}: scrittura fallita: {e}") + return 3 + report.log_ok(f"{ok_label}: aggiunte {len(missing)} chiavi") + return 0 + + +def _ensure_harness_v2_env_keys(env_path: Path, *, dry_run: bool, report: Report) -> int: + return _ensure_env_defaults_from_map( + env_path, + defaults=_HARNESS_V2_ENV_DEFAULTS, + block_title="Harness v2 (append da upgrade-aion)", + dry_run=dry_run, + report=report, + ok_label="Harness v2 env defaults", + ) + + +def _ensure_optimal_tool_format_env_keys( + env_path: Path, *, dry_run: bool, report: Report +) -> int: + return _ensure_env_defaults_from_map( + env_path, + defaults=_OPTIMAL_TOOL_FORMAT_ENV_DEFAULTS, + block_title="Tool format / web tuning (append da upgrade-aion)", + dry_run=dry_run, + report=report, + ok_label="Optimal tool format env defaults", + ) + + def _ensure_cron_env_keys(env_path: Path, *, dry_run: bool, report: Report) -> int: if not env_path.is_file(): report.log_ok("Cron env defaults: .env assente, skip") @@ -1475,6 +1537,16 @@ def _docker_upgrade(args, report: Report) -> int: rc = _ensure_mnemos_env_keys( Path(args.env_file), dry_run=args.dry_run, report=report ) + if rc != 0: + return rc + rc = _ensure_harness_v2_env_keys( + Path(args.env_file), dry_run=args.dry_run, report=report + ) + if rc != 0: + return rc + rc = _ensure_optimal_tool_format_env_keys( + Path(args.env_file), dry_run=args.dry_run, report=report + ) if rc != 0: return rc rc = _ensure_skill_lifecycle_env_keys( @@ -1524,7 +1596,9 @@ def _docker_upgrade(args, report: Report) -> int: _ensure_skill_packages(sys.executable, args.dry_run, report, force_mcp=True) _patch_sql_query_memory_config(sys.executable, args.dry_run, report) - _patch_mempalace_navigation_config(sys.executable, args.dry_run, report) + _patch_mempalace_navigation_config( + sys.executable, args.dry_run, report, env_file=args.env_file + ) extras = ROOT / "scripts/runtime_extras_setup.py" if extras.is_file() and not args.dry_run: @@ -1758,6 +1832,16 @@ def main() -> int: rc = _ensure_mnemos_env_keys( Path(args.env_file), dry_run=args.dry_run, report=report ) + if rc != 0: + return rc + rc = _ensure_harness_v2_env_keys( + Path(args.env_file), dry_run=args.dry_run, report=report + ) + if rc != 0: + return rc + rc = _ensure_optimal_tool_format_env_keys( + Path(args.env_file), dry_run=args.dry_run, report=report + ) if rc != 0: return rc rc = _ensure_skill_view_env_keys( @@ -1813,7 +1897,9 @@ def main() -> int: return rc _ensure_skill_packages(py_exec, args.dry_run, report, force_mcp=True) - _patch_mempalace_navigation_config(py_exec, args.dry_run, report) + _patch_mempalace_navigation_config( + py_exec, args.dry_run, report, env_file=args.env_file + ) _patch_sql_query_memory_config(py_exec, args.dry_run, report) extras = ROOT / "scripts/runtime_extras_setup.py" diff --git a/src/test/test_env_tuning_profiles.py b/src/test/test_env_tuning_profiles.py new file mode 100644 index 00000000..e1ab3496 --- /dev/null +++ b/src/test/test_env_tuning_profiles.py @@ -0,0 +1,70 @@ +"""Tests for scripts/env_tuning_profiles.py""" + +from __future__ import annotations + +from pathlib import Path + +from scripts.env_tuning_profiles import ( + MNEMOS_MIGRATION_MARKER, + MEMPALACE_ENV_KEYS, + apply_env_profile, + detect_mempalace_legacy, + is_protected_env_key, + merge_missing_only, +) + + +def test_is_protected_sandbox_keys(): + assert is_protected_env_key("AION_SANDBOX_BACKEND") + assert is_protected_env_key("AION_PODMAN_SOCKET") + assert not is_protected_env_key("AION_TOOL_RESULT_FORMAT") + + +def test_detect_mempalace_legacy_from_env(tmp_path: Path): + env = tmp_path / ".env" + env.write_text("AION_MEMPALACE_NAV_ENABLED=1\n", encoding="utf-8") + assert detect_mempalace_legacy(env, tmp_path) is True + + +def test_detect_mempalace_skips_after_migration_marker(tmp_path: Path): + env = tmp_path / ".env" + env.write_text( + f"AION_MEMPALACE_NAV_ENABLED=1\n{MNEMOS_MIGRATION_MARKER}=mnemos\n", + encoding="utf-8", + ) + assert detect_mempalace_legacy(env, tmp_path) is False + + +def test_apply_env_profile_removes_mempalace_and_sets_mnemos(tmp_path: Path): + env = tmp_path / ".env" + env.write_text( + "AION_MEMPALACE_NAV_ENABLED=1\nAION_API_URL=http://llm.local\n", + encoding="utf-8", + ) + result = apply_env_profile( + env, + set_values={ + MNEMOS_MIGRATION_MARKER: "mnemos", + "AION_MNEMOS_RECALL_LIMIT": "10", + "AION_API_URL": "should-not-overwrite", + }, + remove_keys=MEMPALACE_ENV_KEYS, + dry_run=False, + skip_protected=True, + ) + text = env.read_text(encoding="utf-8") + assert "AION_MEMPALACE_NAV_ENABLED" not in text + assert f"{MNEMOS_MIGRATION_MARKER}=mnemos" in text + assert "AION_MNEMOS_RECALL_LIMIT=10" in text + assert "AION_API_URL=http://llm.local" in text + assert "should-not-overwrite" not in text + assert "AION_MEMPALACE_NAV_ENABLED" in result["removed"] + + +def test_merge_missing_only(tmp_path: Path): + env = tmp_path / ".env" + env.write_text("AION_HARNESS_V2_TURN=1\n", encoding="utf-8") + missing = merge_missing_only( + env, {"AION_HARNESS_V2_TURN": "1", "AION_HARNESS_V2_TOOLS": "1"} + ) + assert missing == {"AION_HARNESS_V2_TOOLS": "1"} From 6e31dc8fbed2d1f756eb244e2668e0a7cbe3d361 Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:18:05 +0200 Subject: [PATCH 02/10] feat: enhance profile synchronization with new reconciliation option - Added `memory_protocol` to critical skills in `generic_assistant.yaml`. - Introduced `--reconcile-profiles-from-std` argument in `sync_config.py` to allow overwriting local customizations with standard profiles during synchronization. - Updated argument parsing to enforce conditions for using the reconciliation feature, ensuring proper usage with `--force` and `--profiles-only` flags. --- config_std/profiles/generic_assistant.yaml | 1 + scripts/reconcile_runtime_from_std.sh | 31 ++++++++++++++++ scripts/sync_config.py | 22 ++++++++++++ src/test/test_profiles_mnemos.py | 42 ++++++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100755 scripts/reconcile_runtime_from_std.sh create mode 100644 src/test/test_profiles_mnemos.py diff --git a/config_std/profiles/generic_assistant.yaml b/config_std/profiles/generic_assistant.yaml index 60165b14..93c17ddd 100644 --- a/config_std/profiles/generic_assistant.yaml +++ b/config_std/profiles/generic_assistant.yaml @@ -32,6 +32,7 @@ skills: critical_skills: - core_protocol - artifact_protocol +- memory_protocol - web_research_protocol - incremental_execution_protocol native_tool_groups: diff --git a/scripts/reconcile_runtime_from_std.sh b/scripts/reconcile_runtime_from_std.sh new file mode 100755 index 00000000..b91f940e --- /dev/null +++ b/scripts/reconcile_runtime_from_std.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Allinea config/ runtime (profili, skill, native tool registry, MCP) a config_std/. +# Sicuro per Docker: ./config è bind-mountato; non tocca data/ né segreti .env. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PY="${PYTHON_BIN:-python3}" + +echo "==> Reconcile profiles (force da config_std, ignora customizzazioni locali)" +"$PY" scripts/sync_config.py --profiles-only --force --reconcile-profiles-from-std + +echo "==> Sync skills + registry nativo + resto config/" +"$PY" scripts/sync_config.py --force +"$PY" scripts/sync_config.py --skills-only --force + +if [[ -f scripts/sync_proprietary_config.py ]]; then + echo "==> Sync config_proprietary (skill report/docx, …)" + "$PY" scripts/sync_proprietary_config.py --force +fi + +echo "==> Sync MCP servers + merge registry" +"$PY" scripts/sync_mcp_servers.py --force +if [[ -f scripts/merge_mcp_registry_from_std.py ]]; then + "$PY" scripts/merge_mcp_registry_from_std.py +fi + +echo "" +echo "[ok] Runtime config allineato a config_std." +echo " Riavvia il backend: docker compose restart backend (o podman compose)" diff --git a/scripts/sync_config.py b/scripts/sync_config.py index 98366ffb..151da567 100644 --- a/scripts/sync_config.py +++ b/scripts/sync_config.py @@ -56,6 +56,7 @@ def sync_config( *, skills_only: bool = False, profiles_only: bool = False, + reconcile_profiles_from_std: bool = False, root: Path | None = None, ) -> None: script_dir = Path(__file__).parent.absolute() @@ -80,6 +81,8 @@ def sync_config( mode += " — skills/ only" if profiles_only: mode += " — profiles/ only" + if reconcile_profiles_from_std: + mode += " — reconcile profiles (ignore local customizations)" if _NEVER_FORCE_OVERWRITE and force: mode += f" (esclusi: {', '.join(sorted(_NEVER_FORCE_OVERWRITE))})" print(f" mode : {mode}\n") @@ -128,6 +131,12 @@ def sync_config( skipped += 1 elif force: rel_key = profile_rel_key(rel_path) + if rel_key and reconcile_profiles_from_std: + print(f" [RECONCILE] {rel_path}") + shutil.copy2(item, target) + record_profile_after_sync(target, profile_state, rel_key) + overwritten += 1 + continue if rel_key: preserve, reason = should_preserve_profile_on_force( target, item, profile_state, rel_key @@ -185,11 +194,24 @@ def sync_config( action="store_true", help="Sincronizza solo config_std/profiles/ → config/profiles/.", ) + ap.add_argument( + "--reconcile-profiles-from-std", + action="store_true", + help=( + "Con --force: sovrascrive sempre i profili da config_std ignorando " + "customizzazioni locali e .aion-sync-state.json (Mnemos/MCP/skills)." + ), + ) args = ap.parse_args() if args.profiles_only and args.skills_only: ap.error("Usa solo uno tra --profiles-only e --skills-only") + if args.reconcile_profiles_from_std and not args.force: + ap.error("--reconcile-profiles-from-std richiede --force") + if args.reconcile_profiles_from_std and not args.profiles_only: + ap.error("--reconcile-profiles-from-std richiede --profiles-only") sync_config( force=args.force, skills_only=args.skills_only, profiles_only=args.profiles_only, + reconcile_profiles_from_std=args.reconcile_profiles_from_std, ) diff --git a/src/test/test_profiles_mnemos.py b/src/test/test_profiles_mnemos.py new file mode 100644 index 00000000..4e1e7961 --- /dev/null +++ b/src/test/test_profiles_mnemos.py @@ -0,0 +1,42 @@ +"""Regression: default-facing profiles must declare Mnemos native tools.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +PROFILES = ROOT / "config_std" / "profiles" + +# Profiles intentionally without Mnemos (narrow scope). +MNEMOS_EXEMPT = frozenset( + { + "document_extractor.yaml", + "graphic_designer.yaml", + "coding_workspace.yaml", + "data_agent.yaml", + "mcp_integration_advisor.yaml", + } +) + + +def _load(name: str) -> dict: + return yaml.safe_load((PROFILES / name).read_text(encoding="utf-8")) or {} + + +def test_generic_assistant_has_mnemos_native_tools(): + data = _load("generic_assistant.yaml") + groups = data.get("native_tool_groups") or [] + assert "mnemos" in groups, "generic_assistant must list native_tool_groups: mnemos" + assert "memory_protocol" in (data.get("skills") or []) + assert "memory_protocol" in (data.get("critical_skills") or []) + + +def test_default_assistant_profiles_include_mnemos(): + for path in sorted(PROFILES.glob("*.yaml")): + if path.name in MNEMOS_EXEMPT: + continue + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + groups = data.get("native_tool_groups") or [] + assert "mnemos" in groups, f"{path.name} missing native_tool_groups mnemos" From 89fed82bbf22ce09eb6aabd523fad6f60d9aaae6 Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:57:56 +0200 Subject: [PATCH 03/10] feat: implement dynamic client registration for OAuth integration - Added support for RFC 7591 dynamic client registration in the OAuth flow, allowing automatic registration of clients when missing client_id. - Refactored the OAuth start process to incorporate dynamic registration logic, improving handling of cached endpoints without client_id. - Enhanced test coverage for dynamic registration scenarios, ensuring proper functionality when endpoints are cached or registration is disabled. - Updated configuration documentation to reflect changes in OAuth client registration behavior. --- config_std/mcp_registry.yaml | 2 +- src/api/v1/mcp_integrations.py | 165 ++++++++++++++++++++----------- src/test/test_mcp_oauth_start.py | 66 +++++++++++++ 3 files changed, 173 insertions(+), 60 deletions(-) diff --git a/config_std/mcp_registry.yaml b/config_std/mcp_registry.yaml index 2507136c..2a4d8da8 100644 --- a/config_std/mcp_registry.yaml +++ b/config_std/mcp_registry.yaml @@ -26,7 +26,7 @@ grafana: memory: command: "python" args: ["-u", "query_memory/server.py"] - description: "Database di conoscenza e cache query verificate" + description: "QueryMemory (SQL/PromQL cache) + session_search — NOT Mnemos LTM (use native memory_* tools)" code: command: "python" diff --git a/src/api/v1/mcp_integrations.py b/src/api/v1/mcp_integrations.py index 2e065953..219845f0 100644 --- a/src/api/v1/mcp_integrations.py +++ b/src/api/v1/mcp_integrations.py @@ -451,6 +451,88 @@ def _oauth_scope_param(oauth_cfg: Dict[str, Any]) -> str: return "" +async def _oauth_dynamic_client_register( + *, + server_slug: str, + oauth_cfg: Dict[str, Any], + redirect_uri: str, +) -> bool: + """RFC 7591 dynamic registration. Returns True if client_id was obtained.""" + import asyncio + + if not _oauth_dynamic_registration_enabled(): + return False + if (oauth_cfg.get("client_id") or "").strip(): + return False + + reg_endpoint = (oauth_cfg.get("registration_endpoint") or "").strip() + if not reg_endpoint: + auth_server = (oauth_cfg.get("authorization_server") or "").strip() + if auth_server: + from src.mcp_credential_discovery import fetch_authorization_server_metadata + + as_meta = await asyncio.to_thread( + fetch_authorization_server_metadata, auth_server + ) + reg_endpoint = str((as_meta or {}).get("registration_endpoint") or "").strip() + if reg_endpoint: + oauth_cfg["registration_endpoint"] = reg_endpoint + + if not reg_endpoint: + return False + + try: + async with httpx.AsyncClient(timeout=8.0, follow_redirects=True) as hclient: + reg_payload = { + "client_name": "AION Agent", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + reg_resp = await hclient.post( + reg_endpoint, + json=reg_payload, + headers={"Content-Type": "application/json"}, + ) + if reg_resp.status_code not in (200, 201): + logger.warning( + "oauth_start: dynamic registration HTTP %s slug=%s body=%s", + reg_resp.status_code, + server_slug, + reg_resp.text[:300], + ) + return False + reg_data = reg_resp.json() + new_client_id = str(reg_data.get("client_id") or "").strip() + if not new_client_id: + return False + oauth_cfg["client_id"] = new_client_id + oauth_cfg["client_id_source"] = "dynamic_registration" + if reg_data.get("client_secret"): + oauth_cfg["client_secret"] = reg_data["client_secret"] + from src.runtime.mcp_oauth_audit import log_dynamic_client_registration + + log_dynamic_client_registration( + server_slug=server_slug, + registration_endpoint=reg_endpoint, + client_id=new_client_id, + ) + logger.info( + "oauth_start: dynamic client registration OK slug=%s client_id=%s", + server_slug, + new_client_id, + ) + return True + except Exception as reg_exc: + logger.warning( + "oauth_start: dynamic client registration failed slug=%s: %s", + server_slug, + reg_exc, + ) + return False + + async def _resolve_oauth_config_for_server( server_slug: str, oauth_cfg: Dict[str, Any] ) -> Dict[str, Any]: @@ -706,63 +788,12 @@ async def oauth_start( ] modified = True - # ─── Dynamic Client Registration (RFC 7591) ── - reg_endpoint = oauth_cfg.get("registration_endpoint") or ( - as_meta or {} - ).get("registration_endpoint") - if ( - _oauth_dynamic_registration_enabled() - and reg_endpoint - and not oauth_cfg.get("client_id") + if await _oauth_dynamic_client_register( + server_slug=server_slug, + oauth_cfg=oauth_cfg, + redirect_uri=redirect_uri, ): - try: - async with httpx.AsyncClient( - timeout=8.0, follow_redirects=True - ) as hclient: - reg_payload = { - "client_name": "AION Agent", - "redirect_uris": [redirect_uri], - "grant_types": ["authorization_code"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - } - reg_resp = await hclient.post( - reg_endpoint, - json=reg_payload, - headers={"Content-Type": "application/json"}, - ) - if reg_resp.status_code in (200, 201): - reg_data = reg_resp.json() - new_client_id = reg_data.get("client_id") - if new_client_id: - oauth_cfg["client_id"] = new_client_id - oauth_cfg["client_id_source"] = ( - "dynamic_registration" - ) - if reg_data.get("client_secret"): - oauth_cfg["client_secret"] = reg_data[ - "client_secret" - ] - modified = True - from src.runtime.mcp_oauth_audit import ( - log_dynamic_client_registration, - ) - - log_dynamic_client_registration( - server_slug=server_slug, - registration_endpoint=reg_endpoint, - client_id=new_client_id, - ) - logger.info( - "oauth_start: dynamic client registration OK slug=%s client_id=%s", - server_slug, - new_client_id, - ) - except Exception as reg_exc: - logger.warning( - "oauth_start: dynamic client registration failed: %s", - reg_exc, - ) + modified = True except Exception as disc_exc: logger.warning( @@ -771,6 +802,14 @@ async def oauth_start( disc_exc, ) + # Endpoints già in DB ma client_id mancante (es. prima registrazione fallita). + if await _oauth_dynamic_client_register( + server_slug=server_slug, + oauth_cfg=oauth_cfg, + redirect_uri=redirect_uri, + ): + modified = True + # Dopo discovery, riapplica catalogo (corregge endpoint errati su host MCP remoto) oauth_cfg = _apply_catalog_oauth_defaults(oauth_cfg, server_slug, reg_cfg) @@ -805,7 +844,7 @@ async def oauth_start( ), ) - client_id = oauth_cfg.get("client_id") or "" + client_id = (oauth_cfg.get("client_id") or "").strip() needs_client_id = bool(oauth_cfg.get("client_credentials_required")) or ( "login.microsoftonline.com" in str(authorization_endpoint or "").lower() ) @@ -819,6 +858,15 @@ async def oauth_start( f"{redirect_uri}" ), ) + if not client_id: + raise HTTPException( + status_code=400, + detail=( + "OAuth client_id non disponibile per questo connettore. " + "Verifica AION_MCP_OAUTH_DYNAMIC_REGISTRATION=1, la connettività verso il " + f"provider (es. mcp.clickup.com) e che il redirect URI sia corretto: {redirect_uri}" + ), + ) # Fallback per l'authorization_endpoint se non trovato nella discovery if not authorization_endpoint: @@ -841,13 +889,12 @@ async def oauth_start( params: Dict[str, str] = { "response_type": "code", + "client_id": client_id, "redirect_uri": redirect_uri, "state": state, "code_challenge": code_challenge, "code_challenge_method": "S256", } - if client_id: - params["client_id"] = client_id scope = _oauth_scope_param(oauth_cfg) if scope: diff --git a/src/test/test_mcp_oauth_start.py b/src/test/test_mcp_oauth_start.py index 4a30661d..fc127974 100644 --- a/src/test/test_mcp_oauth_start.py +++ b/src/test/test_mcp_oauth_start.py @@ -21,6 +21,72 @@ def test_generate_pkce_pair() -> None: assert "=" not in challenge +@pytest.mark.asyncio +async def test_oauth_start_retries_dynamic_registration_when_endpoints_cached( + oauth_db: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """Endpoints in DB without client_id must not skip RFC 7591 registration.""" + await insert_mcp_server_config( + "clickup", + oauth_config={ + "authorization_server": "https://mcp.clickup.com", + "authorization_endpoint": "https://mcp.clickup.com/oauth/authorize", + "token_url": "https://mcp.clickup.com/oauth/token", + "registration_endpoint": "https://mcp.clickup.com/oauth/register", + }, + ) + + auth = ChatAuthIdentity(via="chat_token", identifier="alice", user_row_id="1") + + class _Resp: + status_code = 201 + + @staticmethod + def json(): + return {"client_id": "dyn-client-xyz"} + + with patch.object(mod, "_cleanup_expired_states"): + with patch("httpx.AsyncClient") as client_cls: + client = AsyncMock() + client_cls.return_value.__aenter__.return_value = client + client.post = AsyncMock(return_value=_Resp()) + result = await mod.oauth_start( + server_slug="clickup", + redirect_uri="http://localhost:8001/v1/integrations/oauth/callback", + auth=auth, + ) + + client.post.assert_called_once() + parsed = urlparse(result["authorization_url"]) + params = parse_qs(parsed.query) + assert params["client_id"] == ["dyn-client-xyz"] + mod._oauth_pending.pop(result["state"], None) + + +@pytest.mark.asyncio +async def test_oauth_start_rejects_missing_client_id_when_registration_disabled( + oauth_db: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AION_MCP_OAUTH_DYNAMIC_REGISTRATION", "0") + await insert_mcp_server_config( + "clickup", + oauth_config={ + "authorization_server": "https://mcp.clickup.com", + "authorization_endpoint": "https://mcp.clickup.com/oauth/authorize", + "token_url": "https://mcp.clickup.com/oauth/token", + }, + ) + auth = ChatAuthIdentity(via="chat_token", identifier="alice", user_row_id="1") + with patch.object(mod, "_cleanup_expired_states"): + with pytest.raises(Exception) as exc: + await mod.oauth_start( + server_slug="clickup", + redirect_uri="http://localhost:8001/v1/integrations/oauth/callback", + auth=auth, + ) + assert "client_id" in str(exc.value.detail).lower() + + @pytest.mark.asyncio async def test_oauth_start_builds_authorization_url(oauth_db: str) -> None: await insert_mcp_server_config( From 52bb7e2b78c58702db2037dda2c4fcdfdb4eb8d9 Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:01:40 +0200 Subject: [PATCH 04/10] feat: improve OAuth redirect URI handling and configuration - Introduced functions to resolve absolute OAuth redirect URIs, accommodating both absolute and relative paths based on environment variables and request headers. - Updated the OAuth flow to utilize the new redirect URI resolution logic, ensuring compatibility with Docker setups and enhancing flexibility for different deployment environments. - Enhanced the `.env.example` file with clearer documentation for the OAuth redirect base URL, providing examples for production use. - Added tests to validate the new redirect URI resolution logic, ensuring correct behavior for various scenarios. --- .env.example | 5 +- admin-ui/lib/mcpOAuthSetup.ts | 12 ++- .../integrations/CredentialConfigDialog.tsx | 4 +- chat-ui/lib/config.ts | 15 ++++ src/api/v1/mcp_integrations.py | 78 ++++++++++++++++--- src/test/test_mcp_oauth_start.py | 65 ++++++++++++++-- 6 files changed, 158 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index dee8cc08..134139c3 100644 --- a/.env.example +++ b/.env.example @@ -465,8 +465,9 @@ AION_MCP_USER_HOME_ISOLATION=1 AION_MCP_OAUTH_DYNAMIC_REGISTRATION=1 # Margine (secondi) prima della scadenza per refresh automatico del token OAuth (default 60) AION_OAUTH_TOKEN_EXPIRY_BUFFER_SECONDS=60 -# URL base redirect OAuth (default: AION_FASTAPI_URL + /v1/integrations/oauth/callback) -# AION_OAUTH_REDIRECT_BASE_URL=http://localhost:8001 +# URL base assoluto per OAuth MCP (obbligatorio in Docker con NEXT_PUBLIC_AION_API_URL=/api). +# Esempio produzione: https://cliente.example.com/api +# AION_OAUTH_REDIRECT_BASE_URL=https://cliente.example.com/api # ----------------------------------------------------------------------------- # Agent DB MCP (SQLite per utente) — mcp_servers/agent_db/server.py, ltm_notifier.py, diff --git a/admin-ui/lib/mcpOAuthSetup.ts b/admin-ui/lib/mcpOAuthSetup.ts index 93907231..27ca401e 100644 --- a/admin-ui/lib/mcpOAuthSetup.ts +++ b/admin-ui/lib/mcpOAuthSetup.ts @@ -18,8 +18,18 @@ export type ConnectorOAuthSetupHints = { scopes: string[]; }; +function absoluteApiBase(): string { + const base = apiBase(); + if (/^https?:\/\//i.test(base)) return base; + if (typeof window !== "undefined") { + const path = base.startsWith("/") ? base : `/${base}`; + return `${window.location.origin}${path}`; + } + return base; +} + export function defaultOAuthRedirectUri(): string { - return `${apiBase()}/v1/integrations/oauth/callback`; + return `${absoluteApiBase().replace(/\/$/, "")}/v1/integrations/oauth/callback`; } export function connectorOAuthSetupHints( diff --git a/chat-ui/components/integrations/CredentialConfigDialog.tsx b/chat-ui/components/integrations/CredentialConfigDialog.tsx index caf6870e..368c0061 100644 --- a/chat-ui/components/integrations/CredentialConfigDialog.tsx +++ b/chat-ui/components/integrations/CredentialConfigDialog.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from "react"; import { Plug, Plus, X } from "lucide-react"; import type { CredentialField, Integration } from "@/components/integrations/types"; -import { apiBase } from "@/lib/config"; +import { apiBase, oauthCallbackRedirectUri } from "@/lib/config"; import { jsonHeaders } from "@/lib/api/aion"; import { oauthManagedFieldKeys, oauthProviderDisplayName } from "@/lib/integrations/oauthLabels"; import { useT } from "@/lib/i18n/use-t"; @@ -73,7 +73,7 @@ export function CredentialConfigDialog({ } async function handleOAuthLogin() { - const redirectUri = `${apiBase()}/v1/integrations/oauth/callback`; + const redirectUri = oauthCallbackRedirectUri(); try { const res = await fetch( `${apiBase()}/v1/integrations/oauth/start?server_slug=${encodeURIComponent(integration.server_slug)}&redirect_uri=${encodeURIComponent(redirectUri)}`, diff --git a/chat-ui/lib/config.ts b/chat-ui/lib/config.ts index 182cdd1a..376a01af 100644 --- a/chat-ui/lib/config.ts +++ b/chat-ui/lib/config.ts @@ -5,6 +5,21 @@ export function apiBase(): string { return "http://localhost:8001"; } +/** Same-origin absolute API base (required for OAuth redirect_uri when apiBase() is `/api`). */ +export function absoluteApiBase(): string { + const base = apiBase(); + if (/^https?:\/\//i.test(base)) return base; + if (typeof window !== "undefined") { + const path = base.startsWith("/") ? base : `/${base}`; + return `${window.location.origin}${path}`; + } + return base; +} + +export function oauthCallbackRedirectUri(): string { + return `${absoluteApiBase().replace(/\/$/, "")}/v1/integrations/oauth/callback`; +} + export function adminUiBase(): string { return (process.env.NEXT_PUBLIC_AION_ADMIN_UI_URL || "http://localhost:3870").replace(/\/$/, ""); } diff --git a/src/api/v1/mcp_integrations.py b/src/api/v1/mcp_integrations.py index 219845f0..ad2fdaf0 100644 --- a/src/api/v1/mcp_integrations.py +++ b/src/api/v1/mcp_integrations.py @@ -410,13 +410,70 @@ def _oauth_dynamic_registration_enabled() -> bool: ) +def _is_absolute_http_url(url: str) -> bool: + u = (url or "").strip().lower() + return u.startswith("http://") or u.startswith("https://") + + +def _public_api_base_url() -> str: + """Absolute public API base (scheme + host + optional path prefix). Skips relative `/api`.""" + for key in ( + "AION_OAUTH_REDIRECT_BASE_URL", + "AION_PUBLIC_API_URL", + "AION_FASTAPI_URL", + ): + val = (os.getenv(key) or "").strip().rstrip("/") + if _is_absolute_http_url(val): + return val + return "" + + def _default_oauth_redirect_uri() -> str: - aion_api_base = ( - os.getenv("AION_OAUTH_REDIRECT_BASE_URL") - or os.getenv("AION_FASTAPI_URL") - or "http://localhost:8001" + base = _public_api_base_url() + if base: + return f"{base}/v1/integrations/oauth/callback" + return "/v1/integrations/oauth/callback" + + +def _resolve_oauth_redirect_uri( + redirect_uri: Optional[str], + request: Optional[Request] = None, +) -> str: + """ + OAuth providers require an absolute redirect_uri. + + chat-ui in Docker uses NEXT_PUBLIC_AION_API_URL=/api (relative, same-origin fetch). + Resolve to https://host/api/v1/integrations/oauth/callback via env or proxy headers. + """ + raw = (redirect_uri or "").strip() or _default_oauth_redirect_uri() + if _is_absolute_http_url(raw): + return raw + + if raw.startswith("/"): + base = _public_api_base_url() + if base: + return f"{base.rstrip('/')}{raw}" + if request is not None: + fwd_proto = ( + request.headers.get("x-forwarded-proto", "").split(",")[0].strip() + ) + fwd_host = ( + request.headers.get("x-forwarded-host", "").split(",")[0].strip() + ) + host = fwd_host or request.headers.get("host", "").split(",")[0].strip() + scheme = fwd_proto or request.url.scheme + if host: + return f"{scheme}://{host}{raw}" + + raise HTTPException( + status_code=400, + detail=( + "OAuth redirect_uri deve essere un URL assoluto " + "(es. https://dominio.example.com/api/v1/integrations/oauth/callback). " + "In Docker imposta AION_OAUTH_REDIRECT_BASE_URL=https:///api " + f"(o AION_PUBLIC_API_URL assoluto). Ricevuto: {raw!r}" + ), ) - return f"{aion_api_base.rstrip('/')}/v1/integrations/oauth/callback" def _apply_catalog_oauth_defaults( @@ -575,6 +632,7 @@ class OAuthCallbackBody(BaseModel): @router.post("/oauth/callback") async def oauth_callback( body: OAuthCallbackBody, + request: Request, auth: ChatAuthIdentity = Depends(require_chat_auth), ) -> Dict[str, Any]: _require_credentials_enabled() @@ -612,7 +670,7 @@ async def oauth_callback( detail=f"OAuth token_url is not configured or discovered for server '{body.server_slug}'.", ) - redirect_uri = body.redirect_uri or _default_oauth_redirect_uri() + redirect_uri = _resolve_oauth_redirect_uri(body.redirect_uri, request) from src.runtime.oauth_token_exchange import ( OAuthTokenExchangeError, @@ -677,6 +735,7 @@ def _generate_pkce_pair() -> tuple[str, str]: @router.get("/oauth/start") async def oauth_start( + request: Request, server_slug: str, redirect_uri: Optional[str] = None, auth: ChatAuthIdentity = Depends(require_chat_auth), @@ -726,8 +785,7 @@ async def oauth_start( oauth_cfg = _apply_catalog_oauth_defaults(oauth_cfg, server_slug, reg_cfg) # Determina il redirect_uri prima della discovery (serve per la dynamic registration) - if not redirect_uri: - redirect_uri = _default_oauth_redirect_uri() + redirect_uri = _resolve_oauth_redirect_uri(redirect_uri, request) modified = False @@ -970,7 +1028,9 @@ async def oauth_callback_redirect(code: str, state: str, request: Request): url=f"{chat_base}/integrations?oauth_status=error&error=Token+URL+non+configurato" ) - callback_redirect_uri = pending.get("redirect_uri") or _default_oauth_redirect_uri() + callback_redirect_uri = _resolve_oauth_redirect_uri( + pending.get("redirect_uri"), request + ) from src.runtime.oauth_token_exchange import ( OAuthTokenExchangeError, diff --git a/src/test/test_mcp_oauth_start.py b/src/test/test_mcp_oauth_start.py index fc127974..a09fc4ee 100644 --- a/src/test/test_mcp_oauth_start.py +++ b/src/test/test_mcp_oauth_start.py @@ -14,6 +14,14 @@ from src.test.mcp_oauth_test_helpers import insert_mcp_server_config +@pytest.fixture +def oauth_request() -> MagicMock: + req = MagicMock() + req.url.scheme = "http" + req.headers = {"host": "localhost:8001"} + return req + + def test_generate_pkce_pair() -> None: verifier, challenge = mod._generate_pkce_pair() assert len(verifier) >= 43 @@ -21,9 +29,42 @@ def test_generate_pkce_pair() -> None: assert "=" not in challenge +def test_resolve_oauth_redirect_uri_absolute_passthrough() -> None: + out = mod._resolve_oauth_redirect_uri( + "https://client.example.com/api/v1/integrations/oauth/callback" + ) + assert out == "https://client.example.com/api/v1/integrations/oauth/callback" + + +def test_resolve_oauth_redirect_uri_relative_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv( + "AION_OAUTH_REDIRECT_BASE_URL", "https://client.example.com/api" + ) + out = mod._resolve_oauth_redirect_uri("/api/v1/integrations/oauth/callback") + assert out == "https://client.example.com/api/v1/integrations/oauth/callback" + + +def test_resolve_oauth_redirect_uri_relative_from_proxy_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("AION_OAUTH_REDIRECT_BASE_URL", raising=False) + monkeypatch.delenv("AION_PUBLIC_API_URL", raising=False) + monkeypatch.delenv("AION_FASTAPI_URL", raising=False) + request = MagicMock() + request.url.scheme = "https" + request.headers = { + "x-forwarded-proto": "https", + "x-forwarded-host": "client.example.com", + } + out = mod._resolve_oauth_redirect_uri( + "/api/v1/integrations/oauth/callback", request + ) + assert out == "https://client.example.com/api/v1/integrations/oauth/callback" + + @pytest.mark.asyncio async def test_oauth_start_retries_dynamic_registration_when_endpoints_cached( - oauth_db: str, monkeypatch: pytest.MonkeyPatch + oauth_db: str, oauth_request: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: """Endpoints in DB without client_id must not skip RFC 7591 registration.""" await insert_mcp_server_config( @@ -51,6 +92,7 @@ def json(): client_cls.return_value.__aenter__.return_value = client client.post = AsyncMock(return_value=_Resp()) result = await mod.oauth_start( + oauth_request, server_slug="clickup", redirect_uri="http://localhost:8001/v1/integrations/oauth/callback", auth=auth, @@ -65,7 +107,7 @@ def json(): @pytest.mark.asyncio async def test_oauth_start_rejects_missing_client_id_when_registration_disabled( - oauth_db: str, monkeypatch: pytest.MonkeyPatch + oauth_db: str, oauth_request: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("AION_MCP_OAUTH_DYNAMIC_REGISTRATION", "0") await insert_mcp_server_config( @@ -80,6 +122,7 @@ async def test_oauth_start_rejects_missing_client_id_when_registration_disabled( with patch.object(mod, "_cleanup_expired_states"): with pytest.raises(Exception) as exc: await mod.oauth_start( + oauth_request, server_slug="clickup", redirect_uri="http://localhost:8001/v1/integrations/oauth/callback", auth=auth, @@ -88,7 +131,9 @@ async def test_oauth_start_rejects_missing_client_id_when_registration_disabled( @pytest.mark.asyncio -async def test_oauth_start_builds_authorization_url(oauth_db: str) -> None: +async def test_oauth_start_builds_authorization_url( + oauth_db: str, oauth_request: MagicMock +) -> None: await insert_mcp_server_config( "clickup", oauth_config={ @@ -104,6 +149,7 @@ async def test_oauth_start_builds_authorization_url(oauth_db: str) -> None: with patch.object(mod, "_cleanup_expired_states"): result = await mod.oauth_start( + oauth_request, server_slug="clickup", redirect_uri="http://localhost:8001/v1/integrations/oauth/callback", auth=auth, @@ -131,7 +177,7 @@ async def test_oauth_start_builds_authorization_url(oauth_db: str) -> None: @pytest.mark.asyncio async def test_oauth_start_skips_dynamic_registration_when_disabled( - oauth_db: str, monkeypatch: pytest.MonkeyPatch + oauth_db: str, oauth_request: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("AION_MCP_OAUTH_DYNAMIC_REGISTRATION", "0") await insert_mcp_server_config( @@ -152,7 +198,7 @@ async def test_oauth_start_skips_dynamic_registration_when_disabled( client_cls.return_value.__aenter__.return_value = client client.post = AsyncMock() result = await mod.oauth_start( - server_slug="remote-svc", redirect_uri=None, auth=auth + oauth_request, server_slug="remote-svc", redirect_uri=None, auth=auth ) client.post.assert_not_called() @@ -211,7 +257,9 @@ def test_apply_catalog_oauth_defaults_for_sharepoint_resolves_tenant() -> None: @pytest.mark.asyncio -async def test_oauth_start_rejects_microsoft_without_client_id(oauth_db: str) -> None: +async def test_oauth_start_rejects_microsoft_without_client_id( + oauth_db: str, oauth_request: MagicMock +) -> None: tenant = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" await insert_mcp_server_config( "microsoft_sharepoint", @@ -226,6 +274,9 @@ async def test_oauth_start_rejects_microsoft_without_client_id(oauth_db: str) -> auth = ChatAuthIdentity(via="chat_token", identifier="alice", user_row_id="1") with pytest.raises(Exception) as exc: await mod.oauth_start( - server_slug="microsoft_sharepoint", redirect_uri=None, auth=auth + oauth_request, + server_slug="microsoft_sharepoint", + redirect_uri=None, + auth=auth, ) assert "client ID" in str(exc.value.detail) From dfd8e1bd5a454acb3abdb6522994cd3e13b5f7f8 Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:09:20 +0200 Subject: [PATCH 05/10] feat: enhance OAuth redirect URI handling and configuration - Improved the resolution logic for OAuth redirect URIs, accommodating both absolute and relative paths based on environment variables and request headers. - Updated the `.env.example` file with clearer documentation and examples for the OAuth redirect base URL. - Modified the Caddyfile to include necessary headers for proper request forwarding. - Added tests to validate the new redirect URI resolution logic, ensuring correct behavior across various scenarios. --- .env.example | 2 + docker-compose.yml | 2 + docker/Caddyfile | 3 ++ src/api/v1/mcp_integrations.py | 77 ++++++++++++++++++++++++-------- src/test/test_mcp_oauth_start.py | 44 ++++++++++++++++++ 5 files changed, 109 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 134139c3..af2f57f1 100644 --- a/.env.example +++ b/.env.example @@ -466,7 +466,9 @@ AION_MCP_OAUTH_DYNAMIC_REGISTRATION=1 # Margine (secondi) prima della scadenza per refresh automatico del token OAuth (default 60) AION_OAUTH_TOKEN_EXPIRY_BUFFER_SECONDS=60 # URL base assoluto per OAuth MCP (obbligatorio in Docker con NEXT_PUBLIC_AION_API_URL=/api). +# Deve essere l'URL browser-facing dietro Caddy (…/api), NON la porta interna uvicorn :8001. # Esempio produzione: https://cliente.example.com/api +# Esempio locale Caddy :8066: http://thinkstation:8066/api # AION_OAUTH_REDIRECT_BASE_URL=https://cliente.example.com/api # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 4adfb355..18e156e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,8 @@ services: # Local default (DOMAIN non valorizzato): http://localhost via Caddy su :80. # In prod sovrascrivere nel .env: AION_PUBLIC_API_URL=https://${DOMAIN}/api AION_PUBLIC_API_URL: ${AION_PUBLIC_API_URL:-http://localhost/api} + # OAuth callback base (browser-facing). Defaults to AION_PUBLIC_API_URL when absolute. + AION_OAUTH_REDIRECT_BASE_URL: ${AION_OAUTH_REDIRECT_BASE_URL:-${AION_PUBLIC_API_URL:-http://localhost/api}} AION_CORS_ORIGINS: ${AION_CORS_ORIGINS:-*} # Root path per il reverse proxy: Caddy strippa /api, root_path glielo # restituisce per generare URL corretti nello schema OpenAPI / Swagger UI. diff --git a/docker/Caddyfile b/docker/Caddyfile index f5b93758..714d2be1 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -45,6 +45,9 @@ write_timeout 0 response_header_timeout 0 } + header_up Host {host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Prefix /api } } diff --git a/src/api/v1/mcp_integrations.py b/src/api/v1/mcp_integrations.py index ad2fdaf0..19a42529 100644 --- a/src/api/v1/mcp_integrations.py +++ b/src/api/v1/mcp_integrations.py @@ -428,11 +428,58 @@ def _public_api_base_url() -> str: return "" +def _oauth_redirect_api_base(request: Optional[Request] = None) -> str: + """ + Browser-facing API base for OAuth callbacks (…/api), not the internal uvicorn URL. + + ``AION_PUBLIC_API_URL=http://localhost:8001`` is valid for server-side fetch but + wrong for OAuth — prefer Caddy ``Host`` + ``/api`` or ``AION_OAUTH_REDIRECT_BASE_URL``. + """ + explicit = (os.getenv("AION_OAUTH_REDIRECT_BASE_URL") or "").strip().rstrip("/") + if _is_absolute_http_url(explicit): + return explicit + + public = (os.getenv("AION_PUBLIC_API_URL") or "").strip().rstrip("/") + if _is_absolute_http_url(public) and public.lower().endswith("/api"): + return public + + chat = (os.getenv("AION_CHAT_URL") or "").strip().rstrip("/") + if _is_absolute_http_url(chat): + return f"{chat}/api" + + if request is not None: + fwd_proto = ( + request.headers.get("x-forwarded-proto", "").split(",")[0].strip() + ) + fwd_host = ( + request.headers.get("x-forwarded-host", "").split(",")[0].strip() + ) + host = fwd_host or request.headers.get("host", "").split(",")[0].strip() + scheme = fwd_proto or request.url.scheme + prefix = (request.headers.get("x-forwarded-prefix") or "/api").strip() or "/api" + if not prefix.startswith("/"): + prefix = f"/{prefix}" + if host: + return f"{scheme}://{host}{prefix.rstrip('/')}" + + domain = (os.getenv("DOMAIN") or "").strip() + if domain and domain not in (":80", "http://:80"): + host = domain.lstrip("http://").lstrip("https://").strip("/") + if host and not host.startswith(":"): + scheme = ( + "https" + if (os.getenv("LETS_ENCRYPT_EMAIL") or "").strip() + else "http" + ) + return f"{scheme}://{host}/api" + + caddy_port = (os.getenv("CADDY_HTTP_PORT") or "80").strip() or "80" + return f"http://localhost:{caddy_port}/api" + + def _default_oauth_redirect_uri() -> str: - base = _public_api_base_url() - if base: - return f"{base}/v1/integrations/oauth/callback" - return "/v1/integrations/oauth/callback" + base = _oauth_redirect_api_base() + return f"{base.rstrip('/')}/v1/integrations/oauth/callback" def _resolve_oauth_redirect_uri( @@ -450,20 +497,12 @@ def _resolve_oauth_redirect_uri( return raw if raw.startswith("/"): - base = _public_api_base_url() - if base: - return f"{base.rstrip('/')}{raw}" - if request is not None: - fwd_proto = ( - request.headers.get("x-forwarded-proto", "").split(",")[0].strip() - ) - fwd_host = ( - request.headers.get("x-forwarded-host", "").split(",")[0].strip() - ) - host = fwd_host or request.headers.get("host", "").split(",")[0].strip() - scheme = fwd_proto or request.url.scheme - if host: - return f"{scheme}://{host}{raw}" + base = _oauth_redirect_api_base(request) + if raw.startswith("/api/"): + # /api/v1/... behind Caddy → {base}/v1/... when base already ends with /api + suffix = raw[4:] # "/v1/integrations/oauth/callback" + return f"{base.rstrip('/')}{suffix}" + return f"{base.rstrip('/')}{raw}" raise HTTPException( status_code=400, @@ -471,7 +510,7 @@ def _resolve_oauth_redirect_uri( "OAuth redirect_uri deve essere un URL assoluto " "(es. https://dominio.example.com/api/v1/integrations/oauth/callback). " "In Docker imposta AION_OAUTH_REDIRECT_BASE_URL=https:///api " - f"(o AION_PUBLIC_API_URL assoluto). Ricevuto: {raw!r}" + f"(o AION_PUBLIC_API_URL che termini con /api). Ricevuto: {raw!r}" ), ) diff --git a/src/test/test_mcp_oauth_start.py b/src/test/test_mcp_oauth_start.py index a09fc4ee..3a1c8f79 100644 --- a/src/test/test_mcp_oauth_start.py +++ b/src/test/test_mcp_oauth_start.py @@ -50,11 +50,14 @@ def test_resolve_oauth_redirect_uri_relative_from_proxy_headers( monkeypatch.delenv("AION_OAUTH_REDIRECT_BASE_URL", raising=False) monkeypatch.delenv("AION_PUBLIC_API_URL", raising=False) monkeypatch.delenv("AION_FASTAPI_URL", raising=False) + monkeypatch.delenv("AION_CHAT_URL", raising=False) + monkeypatch.delenv("DOMAIN", raising=False) request = MagicMock() request.url.scheme = "https" request.headers = { "x-forwarded-proto": "https", "x-forwarded-host": "client.example.com", + "x-forwarded-prefix": "/api", } out = mod._resolve_oauth_redirect_uri( "/api/v1/integrations/oauth/callback", request @@ -62,6 +65,47 @@ def test_resolve_oauth_redirect_uri_relative_from_proxy_headers( assert out == "https://client.example.com/api/v1/integrations/oauth/callback" +def test_resolve_oauth_redirect_uri_ignores_backend_port_public_api_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """AION_PUBLIC_API_URL=http://localhost:8001 is internal; OAuth needs Caddy /api.""" + monkeypatch.delenv("AION_OAUTH_REDIRECT_BASE_URL", raising=False) + monkeypatch.setenv("AION_PUBLIC_API_URL", "http://localhost:8001") + request = MagicMock() + request.url.scheme = "http" + request.headers = { + "host": "thinkstation:8066", + "x-forwarded-proto": "http", + "x-forwarded-host": "thinkstation:8066", + "x-forwarded-prefix": "/api", + } + out = mod._resolve_oauth_redirect_uri( + "/api/v1/integrations/oauth/callback", request + ) + assert out == "http://thinkstation:8066/api/v1/integrations/oauth/callback" + + +def test_resolve_oauth_redirect_uri_public_api_url_with_api_suffix( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("AION_OAUTH_REDIRECT_BASE_URL", raising=False) + monkeypatch.setenv("AION_PUBLIC_API_URL", "https://client.example.com/api") + out = mod._resolve_oauth_redirect_uri("/api/v1/integrations/oauth/callback") + assert out == "https://client.example.com/api/v1/integrations/oauth/callback" + + +def test_resolve_oauth_redirect_uri_caddy_port_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("AION_OAUTH_REDIRECT_BASE_URL", raising=False) + monkeypatch.delenv("AION_PUBLIC_API_URL", raising=False) + monkeypatch.delenv("AION_CHAT_URL", raising=False) + monkeypatch.setenv("DOMAIN", ":80") + monkeypatch.setenv("CADDY_HTTP_PORT", "8066") + out = mod._resolve_oauth_redirect_uri("/api/v1/integrations/oauth/callback") + assert out == "http://localhost:8066/api/v1/integrations/oauth/callback" + + @pytest.mark.asyncio async def test_oauth_start_retries_dynamic_registration_when_endpoints_cached( oauth_db: str, oauth_request: MagicMock, monkeypatch: pytest.MonkeyPatch From 42c19098092927409dba9118a2421b7d9737281f Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:14:15 +0200 Subject: [PATCH 06/10] feat: refine OAuth redirect URI handling and logging - Enhanced the logic for resolving OAuth redirect URIs to ensure proper formatting, including appending '/api' when necessary. - Improved logging in the OAuth start process to include the raw and resolved redirect URIs for better traceability during dynamic client registration. - Updated the warning message to include the redirect URI in the logs for improved debugging. --- src/api/v1/mcp_integrations.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/api/v1/mcp_integrations.py b/src/api/v1/mcp_integrations.py index 19a42529..42293a45 100644 --- a/src/api/v1/mcp_integrations.py +++ b/src/api/v1/mcp_integrations.py @@ -440,8 +440,11 @@ def _oauth_redirect_api_base(request: Optional[Request] = None) -> str: return explicit public = (os.getenv("AION_PUBLIC_API_URL") or "").strip().rstrip("/") - if _is_absolute_http_url(public) and public.lower().endswith("/api"): - return public + if _is_absolute_http_url(public): + if public.lower().endswith("/api"): + return public + # https://dominio.example.com → https://dominio.example.com/api + return f"{public}/api" chat = (os.getenv("AION_CHAT_URL") or "").strip().rstrip("/") if _is_absolute_http_url(chat): @@ -593,9 +596,10 @@ async def _oauth_dynamic_client_register( ) if reg_resp.status_code not in (200, 201): logger.warning( - "oauth_start: dynamic registration HTTP %s slug=%s body=%s", + "oauth_start: dynamic registration HTTP %s slug=%s redirect_uri=%r body=%s", reg_resp.status_code, server_slug, + redirect_uri, reg_resp.text[:300], ) return False @@ -824,7 +828,15 @@ async def oauth_start( oauth_cfg = _apply_catalog_oauth_defaults(oauth_cfg, server_slug, reg_cfg) # Determina il redirect_uri prima della discovery (serve per la dynamic registration) + raw_redirect_uri = redirect_uri redirect_uri = _resolve_oauth_redirect_uri(redirect_uri, request) + logger.info( + "oauth_start: slug=%s redirect_uri raw=%r resolved=%r oauth_base_env=%r", + server_slug, + raw_redirect_uri, + redirect_uri, + (os.getenv("AION_OAUTH_REDIRECT_BASE_URL") or "").strip() or None, + ) modified = False From 22037a0e2074648373e41fe23721fe80ef7724a4 Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:20:58 +0200 Subject: [PATCH 07/10] feat: enhance chat base URL resolution for OAuth redirects - Introduced logic to derive the chat base URL for OAuth redirects based on environment variables and request headers, improving compatibility in various deployment scenarios. - Updated the `.env.example` file with clearer documentation regarding the usage of `AION_CHAT_URL` and `AION_PUBLIC_CHAT_URL`. - Added tests to validate the new chat base URL resolution logic, ensuring it correctly handles different configurations and proxy headers. --- .env.example | 4 +- src/api/v1/mcp_integrations.py | 66 ++++++++++++++++++++++++++++++-- src/test/test_mcp_oauth_start.py | 33 +++++++++++++++- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index af2f57f1..df9e3d64 100644 --- a/.env.example +++ b/.env.example @@ -902,7 +902,9 @@ AION_ENV=dev # AION_CURRENT_PROFILE_SLUG= # URL pubblico admin-ui per i banner Chainlit; vuoto = usa default in-code. AION_ADMIN_UI_URL=http://localhost:3870 -# URL pubblico del chat-ui (per redirect OAuth dopo autenticazione utente) +# URL pubblico del chat-ui (per redirect OAuth dopo autenticazione utente). +# In Docker/prod usare il dominio browser-facing, NON localhost:8003: +# AION_CHAT_URL=https://cliente.example.com AION_CHAT_URL=http://localhost:8003 # Override base URL OAuth MCP integrations (default: derivato da AION_CHAT_URL) # AION_OAUTH_REDIRECT_BASE_URL= diff --git a/src/api/v1/mcp_integrations.py b/src/api/v1/mcp_integrations.py index 42293a45..cce73726 100644 --- a/src/api/v1/mcp_integrations.py +++ b/src/api/v1/mcp_integrations.py @@ -37,9 +37,61 @@ def _tenant_id() -> str: return (os.getenv("AION_DEFAULT_TENANT_ID") or "default").strip() -def _chat_base_url() -> str: - """URL base del chat-ui per redirect OAuth utente.""" - return (os.getenv("AION_CHAT_URL") or "http://localhost:8003").rstrip("/") +def _is_loopback_host_url(url: str) -> bool: + from urllib.parse import urlparse + + host = (urlparse(url).hostname or "").lower() + return host in ("localhost", "127.0.0.1", "::1") + + +def _chat_base_url(request: Optional[Request] = None) -> str: + """ + Browser-facing chat-ui base URL for OAuth return redirects. + + In Docker prod ``AION_CHAT_URL=http://localhost:8003`` is wrong for the user's + browser — derive from ``AION_OAUTH_REDIRECT_BASE_URL`` / proxy headers when set. + """ + explicit = (os.getenv("AION_CHAT_URL") or "").strip().rstrip("/") + if _is_absolute_http_url(explicit) and not _is_loopback_host_url(explicit): + return explicit + + public_chat = (os.getenv("AION_PUBLIC_CHAT_URL") or "").strip().rstrip("/") + if _is_absolute_http_url(public_chat): + return public_chat + + api_base = _oauth_redirect_api_base(request) + if _is_absolute_http_url(api_base): + low = api_base.rstrip("/").lower() + if low.endswith("/api"): + return api_base.rstrip("/")[:-4] + return api_base.rstrip("/") + + if request is not None: + fwd_proto = ( + request.headers.get("x-forwarded-proto", "").split(",")[0].strip() + ) + fwd_host = ( + request.headers.get("x-forwarded-host", "").split(",")[0].strip() + ) + host = fwd_host or request.headers.get("host", "").split(",")[0].strip() + scheme = fwd_proto or request.url.scheme + if host and not host.startswith("backend:"): + return f"{scheme}://{host}".rstrip("/") + + domain = (os.getenv("DOMAIN") or "").strip() + if domain and domain not in (":80", "http://:80"): + host = domain.lstrip("http://").lstrip("https://").strip("/") + if host and not host.startswith(":"): + scheme = ( + "https" + if (os.getenv("LETS_ENCRYPT_EMAIL") or "").strip() + else "http" + ) + return f"{scheme}://{host}" + + if _is_absolute_http_url(explicit): + return explicit + return "http://localhost:8003" def _credential_user_id(auth: ChatAuthIdentity) -> str: @@ -555,6 +607,7 @@ async def _oauth_dynamic_client_register( server_slug: str, oauth_cfg: Dict[str, Any], redirect_uri: str, + request: Optional[Request] = None, ) -> bool: """RFC 7591 dynamic registration. Returns True if client_id was obtained.""" import asyncio @@ -564,6 +617,9 @@ async def _oauth_dynamic_client_register( if (oauth_cfg.get("client_id") or "").strip(): return False + if not _is_absolute_http_url(redirect_uri): + redirect_uri = _resolve_oauth_redirect_uri(redirect_uri, request) + reg_endpoint = (oauth_cfg.get("registration_endpoint") or "").strip() if not reg_endpoint: auth_server = (oauth_cfg.get("authorization_server") or "").strip() @@ -901,6 +957,7 @@ async def oauth_start( server_slug=server_slug, oauth_cfg=oauth_cfg, redirect_uri=redirect_uri, + request=request, ): modified = True @@ -916,6 +973,7 @@ async def oauth_start( server_slug=server_slug, oauth_cfg=oauth_cfg, redirect_uri=redirect_uri, + request=request, ): modified = True @@ -1036,7 +1094,7 @@ async def oauth_callback_redirect(code: str, state: str, request: Request): _cleanup_expired_states() pending = _oauth_pending.pop(state, None) - chat_base = _chat_base_url() + chat_base = _chat_base_url(request) if not pending: return RedirectResponse( diff --git a/src/test/test_mcp_oauth_start.py b/src/test/test_mcp_oauth_start.py index 3a1c8f79..7a50eaca 100644 --- a/src/test/test_mcp_oauth_start.py +++ b/src/test/test_mcp_oauth_start.py @@ -106,8 +106,37 @@ def test_resolve_oauth_redirect_uri_caddy_port_fallback( assert out == "http://localhost:8066/api/v1/integrations/oauth/callback" -@pytest.mark.asyncio -async def test_oauth_start_retries_dynamic_registration_when_endpoints_cached( +def test_chat_base_url_derives_from_oauth_redirect_base( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AION_CHAT_URL", "http://localhost:8003") + monkeypatch.setenv( + "AION_OAUTH_REDIRECT_BASE_URL", "https://agnt2.aion-asa.com/api" + ) + assert mod._chat_base_url() == "https://agnt2.aion-asa.com" + + +def test_chat_base_url_prefers_explicit_public_chat_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AION_CHAT_URL", "http://localhost:8003") + monkeypatch.setenv("AION_PUBLIC_CHAT_URL", "https://chat.example.com") + assert mod._chat_base_url() == "https://chat.example.com" + + +def test_chat_base_url_from_proxy_headers(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AION_CHAT_URL", raising=False) + monkeypatch.delenv("AION_OAUTH_REDIRECT_BASE_URL", raising=False) + monkeypatch.delenv("AION_PUBLIC_API_URL", raising=False) + request = MagicMock() + request.url.scheme = "https" + request.headers = { + "x-forwarded-proto": "https", + "x-forwarded-host": "agnt2.aion-asa.com", + } + assert mod._chat_base_url(request) == "https://agnt2.aion-asa.com" + + oauth_db: str, oauth_request: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: """Endpoints in DB without client_id must not skip RFC 7591 registration.""" From 7f81d55a6b8fe6678823fcd51532d71854fb5c25 Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:33:45 +0200 Subject: [PATCH 08/10] feat: implement remote-bridge MCP server support and enhance command resolution - Added support for remote-bridge MCP servers, allowing integration with services like ClickUp and Notion. - Introduced a new function to resolve commands and arguments for remote-bridge servers, improving flexibility in command execution. - Updated the Dockerfile to include necessary dependencies for the mcp-remote proxy. - Enhanced tests to validate the new remote-bridge command resolution logic, ensuring correct behavior in various scenarios. --- docker/Dockerfile.backend | 4 ++ docker/package.mcp-remote.json | 8 ++++ src/mcp_manager.py | 57 +++++++++++++++-------------- src/mcp_remote_install.py | 57 ++++++++++++++++++++++++++++- src/test/test_mcp_remote_install.py | 35 +++++++++++++++++- 5 files changed, 131 insertions(+), 30 deletions(-) create mode 100644 docker/package.mcp-remote.json diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 1f694155..6c3d19fa 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -90,6 +90,10 @@ COPY --from=uv-bin /uv /uvx /usr/local/bin/ WORKDIR /app +# mcp-remote proxy for remote-bridge MCP servers (ClickUp, Notion, GitHub Copilot, …) +COPY docker/package.mcp-remote.json ./package.json +RUN npm install --omit=dev + # Application code (runtime dirs config/ and mcp_servers/ come from sync at boot) COPY src/ ./src/ COPY mcp_servers_std/ ./mcp_servers_std/ diff --git a/docker/package.mcp-remote.json b/docker/package.mcp-remote.json new file mode 100644 index 00000000..66a62d90 --- /dev/null +++ b/docker/package.mcp-remote.json @@ -0,0 +1,8 @@ +{ + "private": true, + "name": "aion-mcp-remote-deps", + "description": "mcp-remote stdio proxy for remote-bridge MCP servers (ClickUp, Notion, …)", + "dependencies": { + "mcp-remote": "^0.1.38" + } +} diff --git a/src/mcp_manager.py b/src/mcp_manager.py index 116117eb..4966fa1d 100644 --- a/src/mcp_manager.py +++ b/src/mcp_manager.py @@ -362,18 +362,9 @@ async def _run(self) -> None: raise ValueError( f"MCP server '{self.server_name}' not found in registry" ) - command = config.get("command", "python") - if command == "python": - command = self._manager.get_python_exe(self.server_name) - elif isinstance(command, str) and ( - "/" in command or os.path.sep in command - ): - cmd_path = Path(command) - if not cmd_path.is_absolute(): - cand = _repo_root() / command - if cand.is_file(): - command = str(cand.resolve()) - args = self._manager.resolve_stdio_args(list(config.get("args", []))) + command, args = self._manager.resolve_stdio_spawn_command( + self.server_name, config + ) env = os.environ.copy() project_root = os.getcwd() env.setdefault("FASTMCP_LOG_LEVEL", "ERROR") @@ -1095,6 +1086,28 @@ def resolve_stdio_script_path(arg: str) -> Optional[str]: return path return None + @classmethod + def resolve_stdio_spawn_command( + cls, server_name: str, config: Dict[str, Any] + ) -> Tuple[str, List[str]]: + """Resolve process command + args for stdio / remote-bridge MCP servers.""" + if (config.get("type") or "stdio").lower() == "remote-bridge": + from src.mcp_remote_install import resolve_remote_bridge_spawn + + return resolve_remote_bridge_spawn(config) + + command = config.get("command", "python") + if command == "python": + command = cls.get_python_exe(server_name) + elif isinstance(command, str) and ("/" in command or os.path.sep in command): + cmd_path = Path(command) + if not cmd_path.is_absolute(): + cand = _repo_root() / command + if cand.is_file(): + command = str(cand.resolve()) + args = cls.resolve_stdio_args(list(config.get("args", []))) + return command, args + @classmethod def resolve_stdio_args(cls, args: List[str]) -> List[str]: """Risolve path di file sotto ``mcp_servers/`` o repo root; non convertire flag.""" @@ -1135,10 +1148,9 @@ def stdio_entrypoint_missing( if t in ("sse", "in_process"): return None if t == "remote-bridge": - local_path = os.path.join( - os.getcwd(), "node_modules", "mcp-remote", "dist", "proxy.js" - ) - if os.path.exists(local_path): + from src.mcp_remote_install import mcp_remote_proxy_path + + if mcp_remote_proxy_path(): if not shutil.which("node"): return "node command not found. Node.js is required to run remote-bridge." return None @@ -1739,18 +1751,7 @@ async def call_tool( yield session else: - command = config.get("command", "python") - if command == "python": - command = self.get_python_exe(name) - elif isinstance(command, str) and ( - "/" in command or os.path.sep in command - ): - cmd_path = Path(command) - if not cmd_path.is_absolute(): - cand = _repo_root() / command - if cand.is_file(): - command = str(cand.resolve()) - args = self.resolve_stdio_args(list(config.get("args", []))) + command, args = self.resolve_stdio_spawn_command(name, config) env = os.environ.copy() project_root = os.getcwd() env.setdefault("FASTMCP_LOG_LEVEL", "WARNING") diff --git a/src/mcp_remote_install.py b/src/mcp_remote_install.py index 1eae04b4..cedfb408 100644 --- a/src/mcp_remote_install.py +++ b/src/mcp_remote_install.py @@ -2,8 +2,63 @@ from __future__ import annotations +import os import re -from typing import Any, Dict +import shutil +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +MCP_REMOTE_PROXY_MARKERS = ( + "node_modules/mcp-remote/dist/proxy.js", + "mcp-remote/dist/proxy.js", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def is_mcp_remote_proxy_arg(arg: str) -> bool: + if not isinstance(arg, str): + return False + norm = arg.replace("\\", "/") + return any(norm.endswith(marker) or marker in norm for marker in MCP_REMOTE_PROXY_MARKERS) + + +def mcp_remote_proxy_path() -> Optional[str]: + """Absolute path to locally installed mcp-remote proxy.js, if present.""" + for root in (Path.cwd(), _repo_root()): + candidate = root / "node_modules" / "mcp-remote" / "dist" / "proxy.js" + if candidate.is_file(): + return str(candidate.resolve()) + return None + + +def remote_bridge_tail_args(args: List[str]) -> List[str]: + """Args after the optional proxy.js entrypoint.""" + if args and is_mcp_remote_proxy_arg(args[0]): + return list(args[1:]) + return list(args) + + +def resolve_remote_bridge_spawn(config: Dict[str, Any]) -> Tuple[str, List[str]]: + """ + Spawn command for remote-bridge MCP servers. + + Prefer local ``node_modules/mcp-remote`` (Docker image); fall back to + ``npx -y mcp-remote`` when the package is not installed under /app. + """ + raw_args = list(config.get("args") or []) + tail = remote_bridge_tail_args(raw_args) + + local = mcp_remote_proxy_path() + if local: + return ("node", [local, *tail]) + if shutil.which("npx"): + return ("npx", ["-y", "mcp-remote", *tail]) + + command = str(config.get("command") or "node") + return (command, raw_args) def build_remote_bridge_registry_config( diff --git a/src/test/test_mcp_remote_install.py b/src/test/test_mcp_remote_install.py index 31e23d6e..1ad67634 100644 --- a/src/test/test_mcp_remote_install.py +++ b/src/test/test_mcp_remote_install.py @@ -1,6 +1,9 @@ """Remote MCP install helpers.""" -from src.mcp_remote_install import build_remote_bridge_registry_config +from src.mcp_remote_install import ( + build_remote_bridge_registry_config, + resolve_remote_bridge_spawn, +) def test_build_remote_bridge_oauth(): @@ -19,3 +22,33 @@ def test_build_remote_bridge_none(): ) assert "auth_env_var" not in cfg assert not any("Authorization" in str(a) for a in cfg["args"]) + + +def test_resolve_remote_bridge_spawn_uses_npx_when_local_missing( + monkeypatch, +) -> None: + monkeypatch.setattr( + "src.mcp_remote_install.mcp_remote_proxy_path", lambda: None + ) + cfg = build_remote_bridge_registry_config( + "https://mcp.clickup.com/mcp", "clickup", auth_type="oauth2" + ) + command, args = resolve_remote_bridge_spawn(cfg) + assert command == "npx" + assert args[0:2] == ["-y", "mcp-remote"] + assert args[2] == "https://mcp.clickup.com/mcp" + assert "--header" in args + + +def test_resolve_remote_bridge_spawn_uses_local_proxy(monkeypatch) -> None: + monkeypatch.setattr( + "src.mcp_remote_install.mcp_remote_proxy_path", + lambda: "/app/node_modules/mcp-remote/dist/proxy.js", + ) + cfg = build_remote_bridge_registry_config( + "https://mcp.clickup.com/mcp", "clickup", auth_type="oauth2" + ) + command, args = resolve_remote_bridge_spawn(cfg) + assert command == "node" + assert args[0] == "/app/node_modules/mcp-remote/dist/proxy.js" + assert args[1] == "https://mcp.clickup.com/mcp" From 41559100fdcd379f4c40ee08a4492ded72e4c51b Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:36:23 +0200 Subject: [PATCH 09/10] feat: make get_python_exe a static method in MCPManager - Refactored the get_python_exe method to be static, allowing it to be called without an instance of MCPManager. - This change improves the method's usability and aligns with its functionality, which does not depend on instance-specific data. --- src/mcp_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcp_manager.py b/src/mcp_manager.py index 4966fa1d..93ed8bd4 100644 --- a/src/mcp_manager.py +++ b/src/mcp_manager.py @@ -1030,7 +1030,8 @@ def get_server_config(self, name: str) -> Optional[Dict[str, Any]]: def server_exists(self, name: str) -> bool: return bool(name) and name in self._registry - def get_python_exe(self, server_name: str) -> str: + @staticmethod + def get_python_exe(server_name: str) -> str: venv_path = _repo_root() / "mcp_servers" / server_name / ".venv" if venv_path.exists(): if sys.platform == "win32": From d04e8a3bf07ad98e61df4f4271b0592458abe8e9 Mon Sep 17 00:00:00 2001 From: Giuseppe La Rocca <52716342+JustBeGiusee@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:38:00 +0200 Subject: [PATCH 10/10] refactor: improve code readability and formatting in multiple files - Reformatted code in `mcp_remote_install.py`, `mcp_integrations.py`, and test files to enhance readability by consolidating multi-line statements into single lines. - Adjusted indentation and line breaks for better clarity, ensuring consistent coding style across the codebase. - Updated test cases to align with the new formatting standards, maintaining clarity in assertions and function definitions. --- src/api/v1/mcp_integrations.py | 28 +++++++++------------------- src/mcp_remote_install.py | 4 +++- src/test/test_mcp_oauth_start.py | 14 +++++++------- src/test/test_mcp_remote_install.py | 4 +--- 4 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/api/v1/mcp_integrations.py b/src/api/v1/mcp_integrations.py index cce73726..1564ed26 100644 --- a/src/api/v1/mcp_integrations.py +++ b/src/api/v1/mcp_integrations.py @@ -67,12 +67,8 @@ def _chat_base_url(request: Optional[Request] = None) -> str: return api_base.rstrip("/") if request is not None: - fwd_proto = ( - request.headers.get("x-forwarded-proto", "").split(",")[0].strip() - ) - fwd_host = ( - request.headers.get("x-forwarded-host", "").split(",")[0].strip() - ) + fwd_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip() + fwd_host = request.headers.get("x-forwarded-host", "").split(",")[0].strip() host = fwd_host or request.headers.get("host", "").split(",")[0].strip() scheme = fwd_proto or request.url.scheme if host and not host.startswith("backend:"): @@ -83,9 +79,7 @@ def _chat_base_url(request: Optional[Request] = None) -> str: host = domain.lstrip("http://").lstrip("https://").strip("/") if host and not host.startswith(":"): scheme = ( - "https" - if (os.getenv("LETS_ENCRYPT_EMAIL") or "").strip() - else "http" + "https" if (os.getenv("LETS_ENCRYPT_EMAIL") or "").strip() else "http" ) return f"{scheme}://{host}" @@ -503,12 +497,8 @@ def _oauth_redirect_api_base(request: Optional[Request] = None) -> str: return f"{chat}/api" if request is not None: - fwd_proto = ( - request.headers.get("x-forwarded-proto", "").split(",")[0].strip() - ) - fwd_host = ( - request.headers.get("x-forwarded-host", "").split(",")[0].strip() - ) + fwd_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip() + fwd_host = request.headers.get("x-forwarded-host", "").split(",")[0].strip() host = fwd_host or request.headers.get("host", "").split(",")[0].strip() scheme = fwd_proto or request.url.scheme prefix = (request.headers.get("x-forwarded-prefix") or "/api").strip() or "/api" @@ -522,9 +512,7 @@ def _oauth_redirect_api_base(request: Optional[Request] = None) -> str: host = domain.lstrip("http://").lstrip("https://").strip("/") if host and not host.startswith(":"): scheme = ( - "https" - if (os.getenv("LETS_ENCRYPT_EMAIL") or "").strip() - else "http" + "https" if (os.getenv("LETS_ENCRYPT_EMAIL") or "").strip() else "http" ) return f"{scheme}://{host}/api" @@ -629,7 +617,9 @@ async def _oauth_dynamic_client_register( as_meta = await asyncio.to_thread( fetch_authorization_server_metadata, auth_server ) - reg_endpoint = str((as_meta or {}).get("registration_endpoint") or "").strip() + reg_endpoint = str( + (as_meta or {}).get("registration_endpoint") or "" + ).strip() if reg_endpoint: oauth_cfg["registration_endpoint"] = reg_endpoint diff --git a/src/mcp_remote_install.py b/src/mcp_remote_install.py index cedfb408..be3712a0 100644 --- a/src/mcp_remote_install.py +++ b/src/mcp_remote_install.py @@ -22,7 +22,9 @@ def is_mcp_remote_proxy_arg(arg: str) -> bool: if not isinstance(arg, str): return False norm = arg.replace("\\", "/") - return any(norm.endswith(marker) or marker in norm for marker in MCP_REMOTE_PROXY_MARKERS) + return any( + norm.endswith(marker) or marker in norm for marker in MCP_REMOTE_PROXY_MARKERS + ) def mcp_remote_proxy_path() -> Optional[str]: diff --git a/src/test/test_mcp_oauth_start.py b/src/test/test_mcp_oauth_start.py index 7a50eaca..e3318110 100644 --- a/src/test/test_mcp_oauth_start.py +++ b/src/test/test_mcp_oauth_start.py @@ -36,10 +36,10 @@ def test_resolve_oauth_redirect_uri_absolute_passthrough() -> None: assert out == "https://client.example.com/api/v1/integrations/oauth/callback" -def test_resolve_oauth_redirect_uri_relative_from_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv( - "AION_OAUTH_REDIRECT_BASE_URL", "https://client.example.com/api" - ) +def test_resolve_oauth_redirect_uri_relative_from_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AION_OAUTH_REDIRECT_BASE_URL", "https://client.example.com/api") out = mod._resolve_oauth_redirect_uri("/api/v1/integrations/oauth/callback") assert out == "https://client.example.com/api/v1/integrations/oauth/callback" @@ -110,9 +110,7 @@ def test_chat_base_url_derives_from_oauth_redirect_base( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("AION_CHAT_URL", "http://localhost:8003") - monkeypatch.setenv( - "AION_OAUTH_REDIRECT_BASE_URL", "https://agnt2.aion-asa.com/api" - ) + monkeypatch.setenv("AION_OAUTH_REDIRECT_BASE_URL", "https://agnt2.aion-asa.com/api") assert mod._chat_base_url() == "https://agnt2.aion-asa.com" @@ -137,6 +135,8 @@ def test_chat_base_url_from_proxy_headers(monkeypatch: pytest.MonkeyPatch) -> No assert mod._chat_base_url(request) == "https://agnt2.aion-asa.com" +@pytest.mark.asyncio +async def test_oauth_start_retries_dynamic_registration_when_endpoints_cached( oauth_db: str, oauth_request: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: """Endpoints in DB without client_id must not skip RFC 7591 registration.""" diff --git a/src/test/test_mcp_remote_install.py b/src/test/test_mcp_remote_install.py index 1ad67634..4aaeac21 100644 --- a/src/test/test_mcp_remote_install.py +++ b/src/test/test_mcp_remote_install.py @@ -27,9 +27,7 @@ def test_build_remote_bridge_none(): def test_resolve_remote_bridge_spawn_uses_npx_when_local_missing( monkeypatch, ) -> None: - monkeypatch.setattr( - "src.mcp_remote_install.mcp_remote_proxy_path", lambda: None - ) + monkeypatch.setattr("src.mcp_remote_install.mcp_remote_proxy_path", lambda: None) cfg = build_remote_bridge_registry_config( "https://mcp.clickup.com/mcp", "clickup", auth_type="oauth2" )