rayonne-mcp 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ uv.lock
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: rayonne-mcp
3
+ Version: 0.2.0
4
+ Summary: Rayonne MCP server — pilote le workspace Rayonne (audit marketing, analyse produit, contenus, vidéos, métriques AARRR) depuis tout agent compatible MCP.
5
+ Author-email: Sponge Theory <contact@sponge-theory.ai>
6
+ License: SPONGE THEORY PROPRIETARY LICENSE
7
+ Keywords: claude,marketing,mcp,rayonne,sponge-theory
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx<1,>=0.27
10
+ Requires-Dist: mcp[cli]<2,>=1.2.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Requires-Dist: respx>=0.21; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Rayonne MCP
18
+
19
+ Serveur MCP (stdio) exposant le workspace Rayonne — contenus, soumissions,
20
+ métriques AARRR — à tout agent compatible MCP (Claude Code, Claude Desktop…),
21
+ via l'**API publique v1 en lecture** (`docs/API.md` du repo).
22
+
23
+ ## Prérequis
24
+
25
+ Une clé API workspace (`rk_…`) : Rayonne → **Réglages → Clés API**
26
+ (plan **Business**, entitlement `api_access`).
27
+
28
+ ## Claude Code
29
+
30
+ ```sh
31
+ claude mcp add rayonne \
32
+ --env RAYONNE_API_KEY=rk_xxxxxxxx \
33
+ --env RAYONNE_API_URL=https://rayonne.ai \
34
+ -- uv run --project /chemin/vers/rayonne/mcp rayonne-mcp
35
+ ```
36
+
37
+ ## Claude Desktop (`claude_desktop_config.json`)
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "rayonne": {
43
+ "command": "uv",
44
+ "args": ["run", "--project", "/chemin/vers/rayonne/mcp", "rayonne-mcp"],
45
+ "env": {
46
+ "RAYONNE_API_KEY": "rk_xxxxxxxx",
47
+ "RAYONNE_API_URL": "https://rayonne.ai"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ Le serveur démarre **sans** clé (non fatal) : chaque outil renvoie alors un
55
+ message d'installation clair au lieu d'un crash.
56
+
57
+ ## Outils
58
+
59
+ | Outil | Rend |
60
+ |---|---|
61
+ | `rayonne_overview` | nom, URL produit, autopilote, compteurs contenus/soumissions par statut |
62
+ | `rayonne_list_content` | derniers contenus (+ `predicted_engagement` 0-100) |
63
+ | `rayonne_list_submissions` | statut de publication par plateforme (+ `external_url`) |
64
+ | `rayonne_metrics_aarrr` | snapshot AARRR (stages, geo, by_channel, timeseries, deltas) |
65
+
66
+ ## Tests
67
+
68
+ ```sh
69
+ cd mcp && uv run --extra dev pytest -q
70
+ ```
71
+
72
+ ## Publication marketplace
73
+
74
+ À faire via le flux commun Sponge Theory (même patron qu'`audigeo-mcp` :
75
+ stdio + PyPI, vendored dans `ezeeFlop/claude-plugins`) — voir l'agent
76
+ `mcp-plugin-publisher`.
@@ -0,0 +1,60 @@
1
+ # Rayonne MCP
2
+
3
+ Serveur MCP (stdio) exposant le workspace Rayonne — contenus, soumissions,
4
+ métriques AARRR — à tout agent compatible MCP (Claude Code, Claude Desktop…),
5
+ via l'**API publique v1 en lecture** (`docs/API.md` du repo).
6
+
7
+ ## Prérequis
8
+
9
+ Une clé API workspace (`rk_…`) : Rayonne → **Réglages → Clés API**
10
+ (plan **Business**, entitlement `api_access`).
11
+
12
+ ## Claude Code
13
+
14
+ ```sh
15
+ claude mcp add rayonne \
16
+ --env RAYONNE_API_KEY=rk_xxxxxxxx \
17
+ --env RAYONNE_API_URL=https://rayonne.ai \
18
+ -- uv run --project /chemin/vers/rayonne/mcp rayonne-mcp
19
+ ```
20
+
21
+ ## Claude Desktop (`claude_desktop_config.json`)
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "rayonne": {
27
+ "command": "uv",
28
+ "args": ["run", "--project", "/chemin/vers/rayonne/mcp", "rayonne-mcp"],
29
+ "env": {
30
+ "RAYONNE_API_KEY": "rk_xxxxxxxx",
31
+ "RAYONNE_API_URL": "https://rayonne.ai"
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Le serveur démarre **sans** clé (non fatal) : chaque outil renvoie alors un
39
+ message d'installation clair au lieu d'un crash.
40
+
41
+ ## Outils
42
+
43
+ | Outil | Rend |
44
+ |---|---|
45
+ | `rayonne_overview` | nom, URL produit, autopilote, compteurs contenus/soumissions par statut |
46
+ | `rayonne_list_content` | derniers contenus (+ `predicted_engagement` 0-100) |
47
+ | `rayonne_list_submissions` | statut de publication par plateforme (+ `external_url`) |
48
+ | `rayonne_metrics_aarrr` | snapshot AARRR (stages, geo, by_channel, timeseries, deltas) |
49
+
50
+ ## Tests
51
+
52
+ ```sh
53
+ cd mcp && uv run --extra dev pytest -q
54
+ ```
55
+
56
+ ## Publication marketplace
57
+
58
+ À faire via le flux commun Sponge Theory (même patron qu'`audigeo-mcp` :
59
+ stdio + PyPI, vendored dans `ezeeFlop/claude-plugins`) — voir l'agent
60
+ `mcp-plugin-publisher`.
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # Build the Claude Desktop MCPB bundle from mcp/mcpb/.
3
+ # Output: mcp/dist/rayonne-<VERSION>.mcpb (zip file with renamed extension)
4
+ #
5
+ # Usage: ./build_mcpb.sh
6
+
7
+ set -euo pipefail
8
+
9
+ MCP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
10
+ cd "$MCP_DIR"
11
+
12
+ VERSION=$(grep -E "^version" pyproject.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
13
+ if [ -z "$VERSION" ]; then
14
+ echo "ERROR: cannot parse version from pyproject.toml" >&2
15
+ exit 1
16
+ fi
17
+
18
+ mkdir -p dist
19
+ OUT="${MCP_DIR}/dist/rayonne-${VERSION}.mcpb"
20
+
21
+ if [ ! -d "mcpb" ]; then
22
+ echo "ERROR: mcpb/ template missing" >&2
23
+ exit 1
24
+ fi
25
+
26
+ STAGE=$(mktemp -d)
27
+ trap 'rm -rf "$STAGE"' EXIT
28
+
29
+ cp -R mcpb/* "$STAGE/"
30
+
31
+ # Embed the server source so the bundle is self-contained
32
+ mkdir -p "$STAGE/server"
33
+ cp -R src "$STAGE/server/src"
34
+ cp pyproject.toml "$STAGE/server/pyproject.toml"
35
+ cp README.md "$STAGE/server/README.md"
36
+
37
+ # Bundle the wheel so bootstrap.py can provision dependencies on the user's
38
+ # machine at first run (pip resolves the correct per-platform binaries —
39
+ # vendoring compiled deps like pydantic-core into one cross-platform bundle is
40
+ # not possible). Build the wheel first if it isn't in dist/ yet.
41
+ mkdir -p "$STAGE/server/wheels"
42
+ WHEEL=$(ls -t dist/rayonne_mcp-*.whl 2>/dev/null | head -1 || true)
43
+ if [ -z "${WHEEL:-}" ]; then
44
+ echo "No wheel in dist/ — building it first…"
45
+ "$(dirname "$0")/build_wheel.sh" >/dev/null
46
+ WHEEL=$(ls -t dist/rayonne_mcp-*.whl 2>/dev/null | head -1 || true)
47
+ fi
48
+ if [ -z "${WHEEL:-}" ]; then
49
+ echo "ERROR: could not build/find the rayonne_mcp wheel" >&2
50
+ exit 1
51
+ fi
52
+ cp "$WHEEL" "$STAGE/server/wheels/"
53
+ echo "Bundled wheel: $(basename "$WHEEL")"
54
+
55
+ # Inject version into manifest.json
56
+ MANIFEST="$STAGE/manifest.json"
57
+ if [ -f "$MANIFEST" ]; then
58
+ /usr/bin/sed -i.bak -E "s/\"version\": *\"[^\"]+\"/\"version\": \"${VERSION}\"/" "$MANIFEST"
59
+ rm -f "${MANIFEST}.bak"
60
+ fi
61
+
62
+ # Strip macOS metadata
63
+ find "$STAGE" -name '.DS_Store' -delete 2>/dev/null || true
64
+ find "$STAGE" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true
65
+
66
+ # .mcpb is just a zip
67
+ rm -f "$OUT"
68
+ (cd "$STAGE" && zip -qr "$OUT" .)
69
+
70
+ echo "Built: $OUT ($(du -h "$OUT" | awk '{print $1}'))"
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # Build the Claude Code plugin tar.gz from mcp/claude_code_plugin/.
3
+ # Output: mcp/dist/rayonne-claude-code-<VERSION>.tar.gz
4
+ #
5
+ # Usage: ./build_plugin.sh
6
+
7
+ set -euo pipefail
8
+
9
+ MCP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
10
+ cd "$MCP_DIR"
11
+
12
+ # The plugin is versioned independently of the PyPI server package (the plugin
13
+ # just references `uvx rayonne-mcp`). Read the version from plugin.json.
14
+ VERSION=$(grep -E '"version"' claude_code_plugin/.claude-plugin/plugin.json | head -1 | sed -E 's/.*"version": *"([^"]+)".*/\1/')
15
+ if [ -z "$VERSION" ]; then
16
+ echo "ERROR: cannot parse version from plugin.json" >&2
17
+ exit 1
18
+ fi
19
+
20
+ mkdir -p dist
21
+ OUT="dist/rayonne-claude-code-${VERSION}.tar.gz"
22
+
23
+ if [ ! -d "claude_code_plugin" ]; then
24
+ echo "ERROR: claude_code_plugin/ template missing" >&2
25
+ exit 1
26
+ fi
27
+
28
+ # Stage to a temp dir so the tarball has a clean top-level directory
29
+ STAGE=$(mktemp -d)
30
+ trap 'rm -rf "$STAGE"' EXIT
31
+ cp -R claude_code_plugin "${STAGE}/rayonne"
32
+
33
+ # No server source is bundled: the plugin's .mcp.json launches the MCP server
34
+ # via `uvx rayonne-mcp`, which fetches the published package from PyPI and
35
+ # caches an isolated environment on first run. This keeps the plugin tiny and
36
+ # lets it be distributed from a PUBLIC marketplace without shipping the
37
+ # proprietary server source. (The self-contained path lives in the .mcpb
38
+ # bundle for Claude Desktop, built by build_mcpb.sh.)
39
+
40
+ # Strip macOS metadata and caches
41
+ find "$STAGE" -name '.DS_Store' -delete 2>/dev/null || true
42
+ find "$STAGE" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true
43
+
44
+ tar -czf "$OUT" -C "$STAGE" rayonne
45
+ echo "Built: $OUT ($(du -h "$OUT" | awk '{print $1}'))"
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Build the rayonne-mcp Python wheel.
3
+ # Output: mcp/dist/rayonne_mcp-<VERSION>-py3-none-any.whl + .tar.gz
4
+ #
5
+ # Usage: ./build_wheel.sh
6
+
7
+ set -euo pipefail
8
+
9
+ MCP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
10
+ cd "$MCP_DIR"
11
+
12
+ # Don't rm dist/ — other build scripts have already populated it
13
+ rm -f dist/rayonne_mcp-*.whl dist/rayonne_mcp-*.tar.gz 2>/dev/null || true
14
+
15
+ # Prefer uv (already used in the project), fall back to python -m build
16
+ if command -v uv >/dev/null 2>&1; then
17
+ uv build --wheel --sdist --out-dir dist 2>&1 | tail -5
18
+ else
19
+ "${PYTHON:-python}" -m build --wheel --sdist --outdir dist . 2>&1 | tail -5
20
+ fi
21
+
22
+ echo ""
23
+ echo "Built artifacts:"
24
+ ls -lh dist/rayonne_mcp-*.whl dist/rayonne_mcp-*.tar.gz 2>/dev/null
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # Publish the rayonne-mcp package to PyPI.
3
+ #
4
+ # Builds a fresh wheel + sdist for the current version and uploads them.
5
+ # Already-published versions are skipped (idempotent) via --check-url, so a
6
+ # re-run after a failed upload is safe.
7
+ #
8
+ # Token resolution order (first hit wins):
9
+ # 1. $UV_PUBLISH_TOKEN (CI / one-off overrides)
10
+ # 2. ~/.pypirc [pypi] password (the global PyPI token, chmod 600)
11
+ #
12
+ # Usage: ./build/publish_pypi.sh
13
+ set -euo pipefail
14
+
15
+ MCP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
16
+ cd "$MCP_DIR"
17
+
18
+ VERSION=$(grep -E "^version" pyproject.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
19
+ echo "Publishing rayonne-mcp ${VERSION} to PyPI…"
20
+
21
+ # --- Resolve token ---------------------------------------------------------
22
+ TOKEN="${UV_PUBLISH_TOKEN:-}"
23
+ if [ -z "$TOKEN" ] && [ -f "${HOME}/.pypirc" ]; then
24
+ TOKEN="$(awk '/^\[pypi\]/{f=1} f&&/password/{sub(/^[^=]*=[[:space:]]*/,"");print;exit}' "${HOME}/.pypirc")"
25
+ fi
26
+ if [ -z "$TOKEN" ]; then
27
+ echo "ERROR: no PyPI token found. Set UV_PUBLISH_TOKEN, or create ${TOKEN_FILE}." >&2
28
+ exit 1
29
+ fi
30
+
31
+ # --- Build fresh artifacts -------------------------------------------------
32
+ "$(dirname "$0")/build_wheel.sh" >/dev/null
33
+ WHEEL=$(ls -t dist/rayonne_mcp-"${VERSION}"-*.whl 2>/dev/null | head -1)
34
+ SDIST=$(ls -t dist/rayonne_mcp-"${VERSION}".tar.gz 2>/dev/null | head -1)
35
+ if [ -z "${WHEEL:-}" ] || [ -z "${SDIST:-}" ]; then
36
+ echo "ERROR: could not find built artifacts for ${VERSION} in dist/." >&2
37
+ exit 1
38
+ fi
39
+
40
+ # --- Upload (idempotent: skips versions already on the index) --------------
41
+ uv publish \
42
+ --token "$TOKEN" \
43
+ --check-url https://pypi.org/simple/rayonne-mcp/ \
44
+ "$WHEEL" "$SDIST"
45
+
46
+ echo ""
47
+ echo "Done. Verify: https://pypi.org/project/rayonne-mcp/${VERSION}/"
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "rayonne",
3
+ "version": "0.2.0",
4
+ "description": "Rayonne — l'autopilote marketing des SaaS : audit marketing de ta page produit, analyse, contenus, vidéos, métriques AARRR, depuis Claude Code.",
5
+ "author": {
6
+ "name": "Sponge Theory",
7
+ "email": "contact@sponge-theory.ai",
8
+ "url": "https://rayonne.ai"
9
+ },
10
+ "homepage": "https://rayonne.ai",
11
+ "license": "Sponge Theory Proprietary",
12
+ "keywords": ["marketing", "saas", "rayonne", "audit", "sponge-theory"],
13
+ "userConfig": {
14
+ "api_key": {
15
+ "type": "string",
16
+ "title": "Clé API Rayonne",
17
+ "description": "Générée dans Rayonne → Réglages → Clés API (format rk_…, plan Business requis).",
18
+ "sensitive": true,
19
+ "required": true
20
+ },
21
+ "api_url": {
22
+ "type": "string",
23
+ "title": "URL de l'API",
24
+ "description": "Laisser par défaut sauf instance auto-hébergée.",
25
+ "default": "https://rayonne.ai/api/public/v1"
26
+ },
27
+ "read_only": {
28
+ "type": "boolean",
29
+ "title": "Mode lecture seule",
30
+ "description": "Désactive les outils qui déclenchent un travail (analyse, audit, génération vidéo — la vidéo est facturée). À activer pour explorer sans rien consommer.",
31
+ "default": false
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "mcpServers": {
3
+ "rayonne": {
4
+ "command": "uvx",
5
+ "args": ["rayonne-mcp@0.2.0"],
6
+ "env": {
7
+ "RAYONNE_API_KEY": "${user_config.api_key}",
8
+ "RAYONNE_API_URL": "${user_config.api_url}",
9
+ "RAYONNE_READ_ONLY": "${user_config.read_only}"
10
+ }
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,30 @@
1
+ # Rayonne — plugin Claude Code
2
+
3
+ Pilote ton workspace [Rayonne](https://rayonne.ai) depuis Claude Code :
4
+ audit marketing de ta page produit, analyse produit, génération de contenus
5
+ et de vidéos, métriques AARRR.
6
+
7
+ ## Installation
8
+
9
+ ```
10
+ /plugin marketplace add ezeeFlop/claude-plugins
11
+ /plugin install rayonne
12
+ ```
13
+
14
+ Claude Code demande alors ta **clé API** (Rayonne → Réglages → Clés API,
15
+ format `rk_…`, plan Business).
16
+
17
+ ## Commandes
18
+
19
+ - `/rayonne-audit` — audite le message de ta page produit et dit ce qui
20
+ plafonne tout ce que Rayonne génère pour toi.
21
+ - `/rayonne-status` — état du workspace en quelques lignes.
22
+
23
+ ## Outils
24
+
25
+ Lecture : `rayonne_overview`, `rayonne_list_content`,
26
+ `rayonne_list_submissions`, `rayonne_metrics_aarrr`, `rayonne_list_audits`.
27
+
28
+ Écriture : `rayonne_analyze_product`, `rayonne_marketing_audit`,
29
+ `rayonne_create_video`. Chacun consomme du quota ; la génération vidéo est
30
+ facturée. Coche **Mode lecture seule** à l'installation pour les désactiver.
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Audite le message de ta page produit et dit ce qui plafonne tes contenus.
3
+ ---
4
+
5
+ Lance un audit marketing de la page produit du workspace Rayonne, puis
6
+ présente le résultat.
7
+
8
+ 1. Appelle `rayonne_marketing_audit` (laisse `refresh` à true : il faut noter
9
+ la page **telle qu'elle est**, pas la dernière analyse).
10
+ 2. L'audit tourne en fond. Attends une minute environ, puis relis-le avec
11
+ `rayonne_list_audits`, en reprenant le plus récent.
12
+ 3. Présente dans cet ordre, sans jargon :
13
+ - le **verdict** en premier, tel quel — c'est la phrase qui doit suffire ;
14
+ - la note globale et les axes, en signalant celui qui est le plus bas ;
15
+ - les recommandations, avec leurs phrases de remplacement prêtes à coller.
16
+ 4. Si un audit précédent existe, compare les notes et dis franchement si la
17
+ dernière modification de la page a payé ou non.
18
+
19
+ Ne reformule jamais le verdict et ne traduis pas les citations de la page.
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: État du workspace Rayonne — contenus, soumissions, métriques.
3
+ ---
4
+
5
+ Donne un état des lieux court du workspace Rayonne.
6
+
7
+ 1. `rayonne_overview` pour le workspace et ses compteurs.
8
+ 2. `rayonne_list_content` (10 derniers) et `rayonne_list_submissions`.
9
+ 3. `rayonne_metrics_aarrr` pour les chiffres d'acquisition.
10
+
11
+ Résume en quelques lignes : ce qui a été généré, ce qui est publié, ce qui
12
+ attend une revue, et le chiffre AARRR qui bouge le plus. Signale tout
13
+ contenu bloqué en revue depuis plus d'une semaine.
@@ -0,0 +1,17 @@
1
+ # Rayonne — bundle Claude Desktop
2
+
3
+ Installe ce fichier `.mcpb` dans Claude Desktop (Réglages → Extensions →
4
+ installer depuis un fichier), puis colle ta **clé API Rayonne**
5
+ (Réglages → Clés API dans Rayonne, format `rk_…`, plan Business).
6
+
7
+ Aucun prérequis autre qu'un Python 3.10+ : le bundle provisionne son propre
8
+ environnement au premier lancement.
9
+
10
+ ## À quoi ça sert
11
+
12
+ Demande par exemple : « audite le message de ma page produit ». Rayonne note
13
+ sept axes et te dit lequel plafonne tout le reste — avec les phrases de
14
+ remplacement à coller.
15
+
16
+ Coche **Mode lecture seule** si tu veux explorer sans rien déclencher : la
17
+ génération vidéo consomme du quota et est facturée.
Binary file
@@ -0,0 +1,54 @@
1
+ {
2
+ "manifest_version": "0.2",
3
+ "name": "rayonne",
4
+ "display_name": "Rayonne",
5
+ "version": "0.2.0",
6
+ "description": "Audit marketing de ta page produit, analyse, contenus, vidéos et métriques AARRR — directement dans Claude Desktop.",
7
+ "long_description": "Rayonne est l'autopilote marketing des SaaS. Ce bundle expose ton workspace à Claude Desktop : l'AUDIT MARKETING note le message de ta page produit sur sept axes (hiérarchie du message, clarté, audience, différenciation, douleur, preuve, offre) et dit lequel plafonne tout ce que la plateforme génère pour toi, avec des phrases de remplacement prêtes à coller. S'y ajoutent l'analyse produit, la génération de vidéos, l'état des contenus et des soumissions, et le tableau AARRR. Plan Business requis (entitlement accès API).",
8
+ "author": {
9
+ "name": "Sponge Theory",
10
+ "email": "contact@sponge-theory.ai",
11
+ "url": "https://rayonne.ai"
12
+ },
13
+ "homepage": "https://rayonne.ai",
14
+ "documentation": "https://rayonne.ai/docs/mcp",
15
+ "support": "https://rayonne.ai",
16
+ "server": {
17
+ "type": "python",
18
+ "entry_point": "server/bootstrap.py",
19
+ "mcp_config": {
20
+ "command": "python",
21
+ "args": ["${__dirname}/server/bootstrap.py"],
22
+ "cwd": "${__dirname}/server",
23
+ "env": {
24
+ "PYTHONPATH": "${__dirname}/server/src",
25
+ "RAYONNE_API_KEY": "${user_config.api_key}",
26
+ "RAYONNE_API_URL": "${user_config.api_url}",
27
+ "RAYONNE_READ_ONLY": "${user_config.read_only}"
28
+ }
29
+ }
30
+ },
31
+ "user_config": {
32
+ "api_key": {
33
+ "type": "string",
34
+ "title": "Clé API Rayonne",
35
+ "description": "Générée dans Rayonne → Réglages → Clés API (format rk_…, plan Business requis).",
36
+ "sensitive": true,
37
+ "required": true
38
+ },
39
+ "api_url": {
40
+ "type": "string",
41
+ "title": "URL de l'API",
42
+ "description": "Laisser par défaut sauf instance auto-hébergée.",
43
+ "default": "https://rayonne.ai/api/public/v1",
44
+ "required": false
45
+ },
46
+ "read_only": {
47
+ "type": "boolean",
48
+ "title": "Mode lecture seule",
49
+ "description": "Désactive les outils qui déclenchent un travail (analyse, audit, génération vidéo — la vidéo est facturée). Recommandé pour explorer sans rien consommer.",
50
+ "default": false,
51
+ "required": false
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """Bootstrap launcher for the Rayonne Claude Desktop (.mcpb) bundle.
3
+
4
+ Claude Desktop starts this file with a system Python (manifest
5
+ ``server.type = "python"``). A single ``.mcpb`` must run on macOS, Windows and
6
+ Linux, but the MCP SDK depends on compiled wheels (notably ``pydantic-core``)
7
+ whose binaries differ per OS/architecture — they cannot be vendored portably
8
+ into one bundle. So rather than ship binaries, we provision them on the user's
9
+ own machine on first launch:
10
+
11
+ 1. Fast path — if ``rayonne_mcp`` and its dependencies already import in the
12
+ current interpreter (developer machines, or an already-provisioned
13
+ Python), run the server in-process.
14
+ 2. Otherwise, create a private per-user virtual environment and ``pip
15
+ install`` the wheel bundled next to this script under ``wheels/`` (pip
16
+ resolves the correct per-platform binaries), then exec the installed
17
+ ``rayonne-mcp`` console script. The venv is keyed by the wheel filename,
18
+ so a bundle upgrade provisions a fresh environment automatically.
19
+
20
+ This makes a single cross-platform bundle work with no prerequisite beyond a
21
+ Python 3.10+ interpreter — exactly what the bundle README promises. All
22
+ diagnostics go to stderr; stdout is reserved for the MCP stdio protocol.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ HERE = Path(__file__).resolve().parent # .../server
31
+ SRC = HERE / "src"
32
+ WHEELS = HERE / "wheels"
33
+
34
+
35
+ def _log(msg: str) -> None:
36
+ print(f"[rayonne-mcp bootstrap] {msg}", file=sys.stderr, flush=True)
37
+
38
+
39
+ def _run_in_process() -> bool:
40
+ """Run the server with the current interpreter if deps are importable.
41
+
42
+ Returns False (without side effects beyond a sys.path insert) when the
43
+ dependencies are not present, so the caller can fall back to provisioning.
44
+ """
45
+ if SRC.is_dir():
46
+ sys.path.insert(0, str(SRC))
47
+ try:
48
+ import mcp # noqa: F401
49
+
50
+ from rayonne_mcp.server import run
51
+ except Exception:
52
+ return False
53
+ _log("dependencies already present — running in-process")
54
+ run()
55
+ return True
56
+
57
+
58
+ def _bundled_wheel() -> Path | None:
59
+ if not WHEELS.is_dir():
60
+ return None
61
+ wheels = sorted(WHEELS.glob("rayonne_mcp-*.whl"))
62
+ return wheels[-1] if wheels else None
63
+
64
+
65
+ def _data_root() -> Path:
66
+ if os.name == "nt":
67
+ return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
68
+ if sys.platform == "darwin":
69
+ return Path.home() / "Library" / "Application Support"
70
+ # Linux / other POSIX — honour XDG
71
+ return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
72
+
73
+
74
+ def _venv_dir(wheel: Path) -> Path:
75
+ # Key by wheel filename so a new bundle version provisions a fresh venv.
76
+ return _data_root() / "rayonne-mcp" / f"venv-{wheel.stem}"
77
+
78
+
79
+ def _venv_python(vdir: Path) -> Path:
80
+ if os.name == "nt":
81
+ return vdir / "Scripts" / "python.exe"
82
+ return vdir / "bin" / "python"
83
+
84
+
85
+ def _venv_console(vdir: Path) -> Path:
86
+ if os.name == "nt":
87
+ return vdir / "Scripts" / "rayonne-mcp.exe"
88
+ return vdir / "bin" / "rayonne-mcp"
89
+
90
+
91
+ def _provision(wheel: Path) -> Path:
92
+ import subprocess
93
+ import venv
94
+
95
+ vdir = _venv_dir(wheel)
96
+ marker = vdir / ".provisioned"
97
+ py = _venv_python(vdir)
98
+
99
+ # Fast-path: only skip provisioning when the sentinel records the *exact*
100
+ # wheel filename — a partial install (crash mid-pip) or a different wheel
101
+ # will have a missing or mismatched sentinel and triggers re-provisioning.
102
+ sentinel_ok = marker.exists() and marker.read_text().strip() == wheel.name
103
+ if not sentinel_ok:
104
+ _log(f"provisioning environment in {vdir} (wheel={wheel.name})…")
105
+ if not py.exists():
106
+ venv.EnvBuilder(with_pip=True).create(vdir)
107
+ # Remove a stale sentinel before installing so a crash mid-pip leaves
108
+ # no sentinel (not a stale one that would fool the next launch).
109
+ if marker.exists():
110
+ marker.unlink()
111
+ subprocess.check_call(
112
+ [
113
+ str(py),
114
+ "-m",
115
+ "pip",
116
+ "install",
117
+ "--quiet",
118
+ "--disable-pip-version-check",
119
+ str(wheel),
120
+ ]
121
+ )
122
+ marker.write_text(wheel.name)
123
+ _log("environment ready")
124
+ return vdir
125
+
126
+
127
+ def main() -> None:
128
+ if _run_in_process():
129
+ return
130
+
131
+ wheel = _bundled_wheel()
132
+ if wheel is None:
133
+ _log(
134
+ "FATAL: dependencies are not installed and no bundled wheel was "
135
+ "found. Reinstall the bundle, or run 'uv tool install rayonne-mcp'."
136
+ )
137
+ sys.exit(1)
138
+
139
+ vdir = _provision(wheel)
140
+ console = _venv_console(vdir)
141
+ if console.exists():
142
+ exe, argv = str(console), [str(console)]
143
+ else: # fall back to the module entry point
144
+ py = str(_venv_python(vdir))
145
+ exe, argv = py, [py, "-m", "rayonne_mcp"]
146
+ _log(f"launching {argv[0]}")
147
+ os.execv(exe, argv)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "rayonne-mcp"
3
+ version = "0.2.0"
4
+ description = "Rayonne MCP server — pilote le workspace Rayonne (audit marketing, analyse produit, contenus, vidéos, métriques AARRR) depuis tout agent compatible MCP."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "SPONGE THEORY PROPRIETARY LICENSE" }
8
+ authors = [{ name = "Sponge Theory", email = "contact@sponge-theory.ai" }]
9
+ keywords = ["mcp", "marketing", "rayonne", "claude", "sponge-theory"]
10
+
11
+ dependencies = [
12
+ # <2 : la 2.0 a déplacé FastMCP hors de mcp.server.fastmcp ; la série 1.x
13
+ # est celle qu'audigeo-mcp fait tourner en production.
14
+ "mcp[cli]>=1.2.0,<2",
15
+ "httpx>=0.27,<1",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8",
21
+ "pytest-asyncio>=0.24",
22
+ "respx>=0.21",
23
+ ]
24
+
25
+ [project.scripts]
26
+ rayonne-mcp = "rayonne_mcp.server:main"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/rayonne_mcp"]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
@@ -0,0 +1,3 @@
1
+ """Rayonne MCP server — public read API v1 over stdio MCP."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,122 @@
1
+ """HTTP client for the Rayonne public API v1 (lecture ET écriture).
2
+
3
+ Pure httpx, no MCP imports — testable seule (respx), réutilisable par un
4
+ futur connecteur Zapier. Contrat serveur : ``docs/API.md`` du repo Rayonne.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ DEFAULT_API_URL = "https://rayonne.ai/api/public/v1"
15
+
16
+
17
+ class RayonneConfigError(RuntimeError):
18
+ """Raised on the first call when no API key is configured."""
19
+
20
+
21
+ class RayonneClient:
22
+ """Thin async client. ``async with RayonneClient() as c: ...``"""
23
+
24
+ def __init__(
25
+ self, *, api_url: str | None = None, api_key: str | None = None
26
+ ) -> None:
27
+ raw_url = api_url or os.environ.get("RAYONNE_API_URL") or DEFAULT_API_URL
28
+ self.api_url = raw_url.rstrip("/")
29
+ # Tolère l'URL racine du déploiement : on complète le chemin public.
30
+ if not self.api_url.endswith("/api/public/v1"):
31
+ self.api_url = f"{self.api_url}/api/public/v1"
32
+ self.api_key = api_key or os.environ.get("RAYONNE_API_KEY") or ""
33
+ self._http: httpx.AsyncClient | None = None
34
+
35
+ async def __aenter__(self) -> "RayonneClient":
36
+ self._http = httpx.AsyncClient(timeout=30)
37
+ return self
38
+
39
+ async def __aexit__(self, *exc: object) -> None:
40
+ if self._http is not None:
41
+ await self._http.aclose()
42
+ self._http = None
43
+
44
+ def _headers(self) -> dict[str, str]:
45
+ if not self.api_key:
46
+ raise RayonneConfigError(
47
+ "No Rayonne API key configured. Create one in Rayonne → "
48
+ "Réglages → Clés API (rk_…, Business plan required) and set "
49
+ "RAYONNE_API_KEY."
50
+ )
51
+ return {"Authorization": f"Bearer {self.api_key}"}
52
+
53
+ async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
54
+ assert self._http is not None, "use `async with RayonneClient()`"
55
+ resp = await self._http.get(
56
+ f"{self.api_url}{path}", params=params, headers=self._headers()
57
+ )
58
+ resp.raise_for_status()
59
+ return resp.json()
60
+
61
+ async def overview(self) -> dict[str, Any]:
62
+ return await self._get("/overview") # type: ignore[no-any-return]
63
+
64
+ async def list_content(
65
+ self, *, status: str | None = None, limit: int = 50
66
+ ) -> list[dict[str, Any]]:
67
+ params: dict[str, Any] = {"limit": limit}
68
+ if status:
69
+ params["status"] = status
70
+ return await self._get("/content", params) # type: ignore[no-any-return]
71
+
72
+ async def list_submissions(
73
+ self, *, status: str | None = None, limit: int = 50
74
+ ) -> list[dict[str, Any]]:
75
+ params: dict[str, Any] = {"limit": limit}
76
+ if status:
77
+ params["status"] = status
78
+ return await self._get("/submissions", params) # type: ignore[no-any-return]
79
+
80
+ async def metrics_aarrr(self, *, span_days: int = 14) -> dict[str, Any]:
81
+ return await self._get( # type: ignore[no-any-return]
82
+ "/metrics/aarrr", {"span_days": span_days}
83
+ )
84
+
85
+ # ── Écriture ────────────────────────────────────────────────────────
86
+ #
87
+ # Le serveur applique les mêmes gardes que l'interface (entitlement,
88
+ # quota facturé, garde SSRF) et déduit TOUJOURS le workspace de la clé.
89
+ # Côté client il n'y a donc rien à valider : on transmet, et une erreur
90
+ # métier revient telle quelle au modèle, qui saura la lire.
91
+
92
+ async def _post(self, path: str, body: dict[str, Any]) -> Any:
93
+ assert self._http is not None, "use `async with RayonneClient()`"
94
+ resp = await self._http.post(
95
+ f"{self.api_url}{path}", json=body, headers=self._headers()
96
+ )
97
+ resp.raise_for_status()
98
+ return resp.json()
99
+
100
+ async def create_brief(self, *, product_url: str) -> dict[str, Any]:
101
+ return await self._post( # type: ignore[no-any-return]
102
+ "/briefs", {"product_url": product_url}
103
+ )
104
+
105
+ async def create_video(
106
+ self, *, locale: str = "fr", video_format: str = "9x16"
107
+ ) -> dict[str, Any]:
108
+ return await self._post( # type: ignore[no-any-return]
109
+ "/video", {"locale": locale, "format": video_format}
110
+ )
111
+
112
+ async def run_marketing_audit(
113
+ self, *, locale: str | None = None, refresh: bool = True
114
+ ) -> dict[str, Any]:
115
+ return await self._post( # type: ignore[no-any-return]
116
+ "/marketing-audit", {"locale": locale, "refresh": refresh}
117
+ )
118
+
119
+ async def list_marketing_audits(self, *, limit: int = 10) -> list[dict[str, Any]]:
120
+ return await self._get( # type: ignore[no-any-return]
121
+ "/marketing-audit", {"limit": limit}
122
+ )
@@ -0,0 +1,183 @@
1
+ """FastMCP wiring — lecture ET écriture sur l'API publique Rayonne v1.
2
+
3
+ Démarrage TOUJOURS non fatal (même patron que le MCP audigeo) : le serveur
4
+ monte sans clé, chaque outil rend alors un message d'installation clair —
5
+ jamais un crash au boot de Claude.
6
+
7
+ **Écriture** : les outils qui déclenchent un travail (analyse de produit,
8
+ audit marketing, génération vidéo) consomment du quota et, pour la vidéo,
9
+ de la facturation. Ils sont donc désactivables d'un seul réglage,
10
+ ``RAYONNE_READ_ONLY=true``, que l'utilisateur pose à l'installation quand il
11
+ veut explorer sans rien déclencher. Le serveur ne réimplémente aucune garde :
12
+ l'API publique applique déjà l'entitlement, le quota et la garde SSRF, et
13
+ déduit le workspace de la clé. Ce drapeau est un cran de sûreté côté client,
14
+ pas une politique de sécurité.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ from typing import Any
22
+
23
+ from mcp.server.fastmcp import FastMCP
24
+
25
+ from rayonne_mcp.client import RayonneClient, RayonneConfigError
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ INSTRUCTIONS = """\
30
+ Rayonne MCP — pilot the marketing-autopilot workspace.
31
+
32
+ READ: `rayonne_overview` (workspace + counts), `rayonne_list_content`
33
+ (latest generated pieces, with predicted_engagement 0-100),
34
+ `rayonne_list_submissions` (per-platform publication status, external_url
35
+ when live), `rayonne_metrics_aarrr` (the AARRR dashboard snapshot),
36
+ `rayonne_list_audits` (past marketing audits of the product page).
37
+
38
+ WRITE (each consumes quota; video generation is billed):
39
+ `rayonne_analyze_product` (crawl the product page into a fresh brief),
40
+ `rayonne_marketing_audit` (grade the product page's MESSAGE — what it
41
+ promises, to whom, against which alternative, at what price — and say which
42
+ axis caps everything generated downstream), `rayonne_create_video`.
43
+
44
+ START HERE when a user asks why their generated content is off-message:
45
+ `rayonne_marketing_audit`. A page that leads with its architecture instead
46
+ of the job it does caps every asset built from it — the audit names that
47
+ and returns ready-to-paste rewrites.
48
+
49
+ Auth: set RAYONNE_API_KEY (an `rk_` key from Rayonne → Réglages → Clés API;
50
+ Business plan) and optionally RAYONNE_API_URL. Set RAYONNE_READ_ONLY=true to
51
+ disable every write tool.
52
+ """
53
+
54
+ mcp = FastMCP("rayonne", instructions=INSTRUCTIONS)
55
+
56
+
57
+ async def _call(fn: str, /, **kwargs: Any) -> Any:
58
+ try:
59
+ async with RayonneClient() as client:
60
+ return await getattr(client, fn)(**kwargs)
61
+ except RayonneConfigError as exc:
62
+ return {"error": "not_configured", "message": str(exc)}
63
+ except Exception as exc: # noqa: BLE001 — le message vaut mieux qu'un crash MCP
64
+ return {"error": "request_failed", "message": str(exc)[:300]}
65
+
66
+
67
+ @mcp.tool()
68
+ async def rayonne_overview() -> Any:
69
+ """Workspace summary: name, product URL, autopilot state, and content /
70
+ submission counts by status. Call this first."""
71
+ return await _call("overview")
72
+
73
+
74
+ @mcp.tool()
75
+ async def rayonne_list_content(status: str | None = None, limit: int = 50) -> Any:
76
+ """Latest content pieces (max 100). `status` filters on
77
+ pending/generating/review/approved/rejected/scheduled/published/failed/
78
+ cancelled. Each row carries predicted_engagement (0-100) when scored."""
79
+ return await _call("list_content", status=status, limit=limit)
80
+
81
+
82
+ @mcp.tool()
83
+ async def rayonne_list_submissions(status: str | None = None, limit: int = 50) -> Any:
84
+ """Latest submissions (max 100): platform slug, mode (api/browser_local/
85
+ semi_auto), status, and external_url once published."""
86
+ return await _call("list_submissions", status=status, limit=limit)
87
+
88
+
89
+ def _read_only() -> bool:
90
+ return os.environ.get("RAYONNE_READ_ONLY", "").strip().lower() in {
91
+ "1",
92
+ "true",
93
+ "yes",
94
+ "on",
95
+ }
96
+
97
+
98
+ async def _write(fn: str, /, **kwargs: Any) -> Any:
99
+ """Comme ``_call``, avec le cran de sûreté lecture seule.
100
+
101
+ Le refus est une RÉPONSE, pas une exception : le modèle doit pouvoir la
102
+ lire et l'expliquer à l'utilisateur plutôt que de voir l'outil planter.
103
+ """
104
+ if _read_only():
105
+ return {
106
+ "error": "read_only",
107
+ "message": (
108
+ "Ce serveur Rayonne est en lecture seule (RAYONNE_READ_ONLY). "
109
+ "Retire ce réglage dans la configuration du plugin pour "
110
+ "autoriser les actions qui consomment du quota."
111
+ ),
112
+ }
113
+ return await _call(fn, **kwargs)
114
+
115
+
116
+ @mcp.tool()
117
+ async def rayonne_metrics_aarrr(span_days: int = 14) -> Any:
118
+ """The AARRR snapshot (stages, geo, by_channel, timeseries, deltas) over
119
+ a sliding window of 1-90 days."""
120
+ return await _call("metrics_aarrr", span_days=span_days)
121
+
122
+
123
+ def main() -> None:
124
+ logging.basicConfig(level=logging.INFO)
125
+ mcp.run()
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
130
+
131
+
132
+ @mcp.tool()
133
+ async def rayonne_list_audits(limit: int = 10) -> Any:
134
+ """Les audits marketing passés de la page produit (verdict, note, axes).
135
+
136
+ L'historique montre si une réécriture de page a payé : chaque audit
137
+ porte sa date et sa note.
138
+ """
139
+ return await _call("list_marketing_audits", limit=limit)
140
+
141
+
142
+ @mcp.tool()
143
+ async def rayonne_marketing_audit(
144
+ locale: str | None = None, refresh: bool = True
145
+ ) -> Any:
146
+ """Audite le MESSAGE de la page produit et rend un verdict actionnable.
147
+
148
+ Note sept axes — hiérarchie du message, clarté, audience nommée,
149
+ différenciation, douleur vécue, preuve, offre — et nomme celui qui
150
+ plafonne tout le reste. Une page qui mène avec son architecture au lieu
151
+ du métier qu'elle rend bride tout ce que la plateforme génère ensuite :
152
+ c'est ce que l'audit dit, avec des phrases de remplacement prêtes à
153
+ coller.
154
+
155
+ Distinct d'un audit GEO (visibilité DANS les IA) : ici c'est le message.
156
+
157
+ ``refresh`` relit la page avant de noter — laisse-le à True après une
158
+ modification de la page, sinon tu noterais l'analyse précédente.
159
+ ``locale`` suit le sélecteur de langue du produit (fr/en).
160
+
161
+ 202 : l'audit tourne en fond, relis-le avec `rayonne_list_audits`.
162
+ """
163
+ return await _write("run_marketing_audit", locale=locale, refresh=refresh)
164
+
165
+
166
+ @mcp.tool()
167
+ async def rayonne_analyze_product(product_url: str) -> Any:
168
+ """Analyse la page produit et en tire un brief frais (crawl + extraction).
169
+
170
+ C'est la matière première de tout ce que la plateforme génère. 202 :
171
+ l'analyse tourne en fond.
172
+ """
173
+ return await _write("create_brief", product_url=product_url)
174
+
175
+
176
+ @mcp.tool()
177
+ async def rayonne_create_video(locale: str = "fr", video_format: str = "9x16") -> Any:
178
+ """Génère une vidéo courte à partir du brief courant.
179
+
180
+ ATTENTION : consomme du quota vidéo et est FACTURÉE. Formats : 9x16
181
+ (vertical), 16x9 (paysage), 1x1 (carré). 202 : le rendu tourne en fond.
182
+ """
183
+ return await _write("create_video", locale=locale, video_format=video_format)
@@ -0,0 +1,106 @@
1
+ """RayonneClient contre le contrat public v1 (respx, aucun réseau)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+ import pytest
9
+ import respx
10
+ from httpx import Response
11
+
12
+ from rayonne_mcp.client import RayonneClient, RayonneConfigError
13
+
14
+ BASE = "https://rayonne.test/api/public/v1"
15
+
16
+
17
+ @respx.mock
18
+ async def test_overview_hits_the_public_path_with_bearer() -> None:
19
+ route = respx.get(f"{BASE}/overview").mock(
20
+ return_value=Response(200, json={"name": "WS", "autopilot": True})
21
+ )
22
+ async with RayonneClient(api_url="https://rayonne.test", api_key="rk_x") as c:
23
+ out = await c.overview()
24
+ assert out["name"] == "WS"
25
+ req = route.calls.last.request
26
+ assert req.headers["authorization"] == "Bearer rk_x"
27
+
28
+
29
+ @respx.mock
30
+ async def test_list_content_passes_status_and_limit() -> None:
31
+ route = respx.get(f"{BASE}/content").mock(return_value=Response(200, json=[]))
32
+ async with RayonneClient(api_url=BASE, api_key="rk_x") as c:
33
+ await c.list_content(status="approved", limit=10)
34
+ url = str(route.calls.last.request.url)
35
+ assert "status=approved" in url and "limit=10" in url
36
+
37
+
38
+ async def test_missing_key_raises_config_error(monkeypatch) -> None:
39
+ monkeypatch.delenv("RAYONNE_API_KEY", raising=False)
40
+ async with RayonneClient(api_url=BASE, api_key="") as c:
41
+ with pytest.raises(RayonneConfigError):
42
+ await c.overview()
43
+
44
+
45
+ @respx.mock
46
+ async def test_metrics_span_days() -> None:
47
+ route = respx.get(f"{BASE}/metrics/aarrr").mock(
48
+ return_value=Response(200, json={"stages": {}})
49
+ )
50
+ async with RayonneClient(api_url=BASE, api_key="rk_x") as c:
51
+ await c.metrics_aarrr(span_days=30)
52
+ assert "span_days=30" in str(route.calls.last.request.url)
53
+
54
+
55
+ # ── Écriture (2026-09-01) ────────────────────────────────────────────────
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_running_an_audit_posts_to_the_public_api(respx_mock):
60
+ route = respx_mock.post("https://x.io/api/public/v1/marketing-audit").mock(
61
+ return_value=httpx.Response(202, json={"job_id": "j", "resource_id": None})
62
+ )
63
+ async with RayonneClient(api_url="https://x.io", api_key="rk_x") as c:
64
+ out = await c.run_marketing_audit(locale="en", refresh=True)
65
+ assert out["job_id"] == "j"
66
+ body = json.loads(route.calls[0].request.content)
67
+ assert body == {"locale": "en", "refresh": True}
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_a_write_without_a_key_raises_the_config_error():
72
+ """Même message d'installation que pour la lecture : jamais un crash
73
+ opaque au premier appel."""
74
+ async with RayonneClient(api_url="https://x.io", api_key="") as c:
75
+ with pytest.raises(RayonneConfigError):
76
+ await c.create_brief(product_url="https://y.io")
77
+
78
+
79
+ def test_read_only_disables_every_write_tool(monkeypatch):
80
+ """Le cran de sûreté est une RÉPONSE lisible, pas une exception : le
81
+ modèle doit pouvoir l'expliquer à l'utilisateur."""
82
+ import asyncio
83
+
84
+ from rayonne_mcp import server
85
+
86
+ monkeypatch.setenv("RAYONNE_READ_ONLY", "true")
87
+ out = asyncio.run(server._write("create_video"))
88
+ assert out["error"] == "read_only"
89
+
90
+
91
+ def test_read_only_off_lets_writes_through(monkeypatch):
92
+ import asyncio
93
+
94
+ from rayonne_mcp import server
95
+
96
+ monkeypatch.delenv("RAYONNE_READ_ONLY", raising=False)
97
+ called: list = []
98
+
99
+ async def fake_call(fn, /, **kw):
100
+ called.append(fn)
101
+ return {"ok": True}
102
+
103
+ monkeypatch.setattr(server, "_call", fake_call)
104
+ out = asyncio.run(server._write("create_video", locale="fr"))
105
+ assert out == {"ok": True}
106
+ assert called == ["create_video"]