From 5ef6c21756f33f50371d91048c97453025f0958e Mon Sep 17 00:00:00 2001 From: Spark Date: Thu, 22 Oct 2026 03:31:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(0.26.0):=20=C3=A9critures=20MCP=20minimale?= =?UTF-8?q?s=20=E2=80=94=20create=5Fissue=20+=20add=5Fcomment=20gard=C3=A9?= =?UTF-8?q?s=20par=20jeton=20kbt=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 35 +++++++ README.md | 20 ++-- app/config.py | 2 +- app/main.py | 154 +++++++++++++++++++++++------ pyproject.toml | 2 +- tests/test_mcp_writes.py | 202 +++++++++++++++++++++++++++++++++++++++ tests/test_palier3.py | 8 +- 7 files changed, 385 insertions(+), 38 deletions(-) create mode 100644 tests/test_mcp_writes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fda827..4ef925d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 0.26.0 — 2026-10-21 + +### Ajouté — écritures MCP minimales et sûres (service d'agents) + +Le serveur MCP n'était qu'en lecture depuis la 0.5.0. Le service +d'agents (jeton `kbt_` posé en production) peut désormais créer +des cartes et commenter, et rien d'autre : aucune suppression, +aucun changement d'état. + +- **`create_issue`** — arguments : `owner` (chaîne, requis), + `repo` (chaîne, requis), `title` (chaîne, requis), `body` + (chaîne, optionnel), `column` (optionnel, une des colonnes du + board, `backlog` par défaut). Réutilise le service de création + existant (`_do_capture` → `create_issue` du client de forge, + Gitea ou GitHub selon la connexion qui détient le dépôt) : la + carte est placée sur le board et journalisée (source `mcp`). + Réponse JSON : `number`, `title`, `html_url`, `repo_full_name`, + `column`. +- **`add_comment`** — arguments : `owner`, `repo`, `number` + (entier), `body` — tous requis. Réutilise `create_comment` du + client de forge (mêmes validations que l'API web : corps non + vide, 10 000 caractères max) et journalise l'événement + `commented` (source `mcp`). Réponse JSON : `id`, `body`, + `html_url`, `repo_full_name`, `number`. +- **Garde inchangée** : les deux outils passent par le même + `/mcp` et le même middleware que les lectures — session, + Basic ou `Authorization: Bearer kbt_…` ; un jeton ne + déverrouille toujours pas l'interface web, et sa révocation + bloque aussitôt les écritures. Erreurs métier au format + existant du serveur (résultat `isError` avec le message). +- `GET /mcp` déclare désormais `read_only: false` et la liste + `write_tools` (honnêteté de la découverte). +- **Redéploiement Docker de Kanbante à la charge de Bruno** + pour activer cette version en production. + ## 0.25.0 — 2026-10-21 ### Ajouté — jetons d'API personnels pour les surfaces machine (MCP) diff --git a/README.md b/README.md index be6a0db..ca057b2 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne - 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`) - 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié -- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents +- 🤖 **Serveur MCP** — 7 outils de lecture (boards, focus, recherche, métriques) + 2 écritures minimales (`create_issue`, `add_comment`) exposés en JSON-RPC sur `/mcp` pour vos agents - 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail - 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique - 📝 **Notes** — bloc-notes par projet @@ -212,15 +212,17 @@ kanbante/ | GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) | | GET | `/api/briefing` | Briefing en JSON + texte formaté | | POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant | -| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) | +| GET/POST | `/mcp` | Serveur MCP (JSON-RPC 2.0) : lectures + `create_issue` / `add_comment` | | GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt | | DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt | | POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue | ## Brancher un client MCP -Kanbante expose un serveur MCP **en lecture seule** (aucune écriture : -les lectures d'abord). Configuration type (transport HTTP) : +Kanbante expose un serveur MCP : lectures d'abord, plus deux +écritures minimales et sûres pour les agents — créer une carte et +commenter (aucune suppression, aucun changement d'état). +Configuration type (transport HTTP) : ```json { @@ -233,9 +235,13 @@ les lectures d'abord). Configuration type (transport HTTP) : ``` Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`, -`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth -Kanbante est active, le client doit envoyer l'en-tête -`Authorization` correspondant. +`search_issues`, `get_activity`, `get_metrics`, `create_issue` +(`owner`, `repo`, `title`, `body` et `column` optionnels), +`add_comment` (`owner`, `repo`, `number`, `body`). Si +l'authentification Kanbante est active, le client doit envoyer +l'en-tête `Authorization` correspondant : Basic, ou un jeton +d'API personnel `Bearer kbt_…` (Mon profil → Jetons d'API) — +honoré uniquement sur `/mcp` et `/api/`, jamais sur le web. ## Activer le temps réel (webhook Gitea) diff --git a/app/config.py b/app/config.py index bf6e91f..3946d2b 100644 --- a/app/config.py +++ b/app/config.py @@ -104,7 +104,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.25.0" +APP_VERSION = "0.26.0" def _env_bool(name: str, default: bool) -> bool: diff --git a/app/main.py b/app/main.py index 116fc70..7867778 100644 --- a/app/main.py +++ b/app/main.py @@ -4349,27 +4349,14 @@ async def sse_event_stream(request: Request): hub.unsubscribe(queue) -async def _do_capture( - title: str, body: str, repo_full_name: str, column: str = "backlog" -) -> dict: - """Create a Gitea issue from a capture and place its card. +async def _client_for_repo(repo_full_name: str): + """(client, connection_id) holding this repo, default first. - Shared by POST /api/capture, the /capture page and the ntfy - listener. Raises ValueError for bad input (callers map to 400). + Board-less callers (capture, serveur MCP) have no request / + workspace scope: the connection holding the repo among the + global ones wins, so a card can target a GitHub project too + (0.24.0). Falls back to the historical client. """ - title = (title or "").strip() - if not title or len(title) > 255: - raise ValueError("Title required (max 255 chars)") - if column not in KANBAN_COLUMNS: - raise ValueError("Invalid column") - parts = _split_full_name(repo_full_name) - if parts is None: - raise ValueError("Invalid repo (expected owner/name)") - owner, repo = parts - # Capture has no board context: find the connection holding - # this repo among the global ones (default first), so a card - # can be captured into a GitHub project too (0.24.0). - client, conn_id = gitea, registry.default_id for candidate in registry.global_clients(): if candidate is gitea: continue @@ -4380,8 +4367,33 @@ async def _do_capture( except Exception: continue if repo_full_name in names: - client, conn_id = candidate, candidate.connection_id - break + return candidate, candidate.connection_id + return gitea, registry.default_id + + +async def _do_capture( + title: str, + body: str, + repo_full_name: str, + column: str = "backlog", + source: str = "capture", +) -> dict: + """Create a Gitea issue from a capture and place its card. + + Shared by POST /api/capture, the /capture page, the ntfy + listener and the MCP create_issue tool (0.26.0, source="mcp"). + Raises ValueError for bad input (callers map to 400). + """ + title = (title or "").strip() + if not title or len(title) > 255: + raise ValueError("Title required (max 255 chars)") + if column not in KANBAN_COLUMNS: + raise ValueError("Invalid column") + parts = _split_full_name(repo_full_name) + if parts is None: + raise ValueError("Invalid repo (expected owner/name)") + owner, repo = parts + client, conn_id = await _client_for_repo(repo_full_name) issue = await client.create_issue(owner, repo, title, body or "") # La carte vit dans le premier espace qui détient la connexion. ws_id = next( @@ -4398,7 +4410,7 @@ async def _do_capture( ) log_event( repo_full_name, issue["number"], "created", - to_column=column, source="capture", + to_column=column, source=source, workspace_id=ws_id, connection_id=conn_id, ) await _publish_board_changed(repo_full_name) @@ -5364,7 +5376,11 @@ async def api_toggle_issue_label( } -# ── MCP server (read-only tools over JSON-RPC) ───────────── +# ── MCP server (tools over JSON-RPC) ─────────────────────── +# Lectures depuis la 0.5.0 ; écritures minimales (create_issue, +# add_comment) depuis la 0.26.0, gardées par la même authentification +# que tout /mcp (session, Basic ou jeton d'API kbt_ — voir le +# middleware : un jeton n'ouvre jamais l'interface web). MCP_TOOLS = [ { @@ -5430,11 +5446,49 @@ MCP_TOOLS = [ "properties": {"repo": {"type": "string"}}, }, }, + { + "name": "create_issue", + "description": "Create an issue (card) in a repo and place it on " + "the board (backlog by default). Write tool — same /mcp " + "authentication as the reads (kbt_ API token, session or Basic).", + "inputSchema": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"}, + "title": {"type": "string"}, + "body": {"type": "string"}, + "column": {"type": "string", "enum": list(KANBAN_COLUMNS)}, + }, + "required": ["owner", "repo", "title"], + }, + }, + { + "name": "add_comment", + "description": "Add a comment to an existing issue (card). " + "Write tool — same /mcp authentication as the reads " + "(kbt_ API token, session or Basic).", + "inputSchema": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "repo": {"type": "string"}, + "number": {"type": "integer"}, + "body": {"type": "string"}, + }, + "required": ["owner", "repo", "number", "body"], + }, + }, ] async def _mcp_call_tool(name: str, args: dict) -> str: - """Execute one read-only MCP tool and return text content.""" + """Execute one MCP tool and return text content. + + Reads hit the forge's read API; the two write tools + (create_issue, add_comment) reuse the existing creation / + comment services — never a reimplemented forge call. + """ if name == "list_projects": repos = await gitea.search_repos() active, inactive, archived = classify_repos(repos) @@ -5511,6 +5565,49 @@ async def _mcp_call_tool(name: str, args: dict) -> str: ) if name == "get_metrics": return json.dumps(get_metrics(args.get("repo") or None), ensure_ascii=False) + if name == "create_issue": + owner, repo = args["owner"], args["repo"] + created = await _do_capture( + str(args["title"]), + str(args.get("body") or ""), + f"{owner}/{repo}", + str(args.get("column") or "backlog"), + source="mcp", + ) + return json.dumps(created, ensure_ascii=False) + if name == "add_comment": + owner, repo = args["owner"], args["repo"] + number = int(args["number"]) + body = str(args["body"] or "").strip() + if not body: + raise ValueError("Comment must not be empty") + if len(body) > 10_000: + raise ValueError("Comment too large (max 10000 chars)") + full_name = f"{owner}/{repo}" + client, conn_id = await _client_for_repo(full_name) + comment = await client.create_comment(owner, repo, number, body) + ws_id = next( + ( + ws["id"] + for ws in models.list_workspaces() + if conn_id in models.workspace_connection_ids(ws["id"]) + ), + 1, + ) + log_event( + full_name, number, "commented", source="mcp", + workspace_id=ws_id, connection_id=conn_id, + ) + return json.dumps( + { + "id": comment.get("id"), + "body": comment.get("body", body), + "html_url": comment.get("html_url", ""), + "repo_full_name": full_name, + "number": number, + }, + ensure_ascii=False, + ) raise ValueError(f"Unknown tool: {name}") @@ -5521,7 +5618,8 @@ async def mcp_info(): "name": "kanbante", "version": APP_VERSION, "transport": "streamable-http (JSON-RPC 2.0 over POST)", - "read_only": True, + "read_only": False, + "write_tools": ["create_issue", "add_comment"], "tools": [t["name"] for t in MCP_TOOLS], } @@ -5530,8 +5628,10 @@ async def mcp_info(): async def mcp_endpoint(request: Request): """Minimal MCP server: initialize / tools/list / tools/call / ping. - Read-only by design (Bruno's rule: cross-platform reads first, - writes go through approval flows elsewhere). + Reads since 0.5.0; since 0.26.0, two minimal write tools + (create_issue, add_comment) for the agent service, guarded by + the same /mcp authentication as the reads (session, Basic or + kbt_ API token — a token never unlocks the web UI). """ try: payload = await request.json() diff --git a/pyproject.toml b/pyproject.toml index c09f34f..88f9c1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.25.0" +version = "0.26.0" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_mcp_writes.py b/tests/test_mcp_writes.py new file mode 100644 index 0000000..c487957 --- /dev/null +++ b/tests/test_mcp_writes.py @@ -0,0 +1,202 @@ +"""Écritures MCP minimales (0.26.0) : create_issue + add_comment. + +Le service d'agents doit pouvoir proposer des cartes et commenter +via Kanbante, sans passer par l'interface web. Les deux outils +réutilisent les services existants (création via _do_capture → +client de forge, commentaire via client.create_comment) et sont +gardés par la même authentification que les lectures /mcp : +session, Basic ou jeton d'API kbt_ — jamais l'interface web. +""" + +import json +import uuid + +import pytest +from fastapi.testclient import TestClient + +import app.main as main +from app import accounts, models + + +def _call(client, name, arguments, headers=None): + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + } + return client.post("/mcp", json=payload, headers=headers or {}) + + +def _result_text(response): + result = response.json()["result"] + return result, result["content"][0]["text"] + + +# ── Création de carte ────────────────────────────────────── + +def test_mcp_create_issue_places_card(client, fake_gitea): + r = _call(client, "create_issue", { + "owner": "Projets", "repo": "demo", + "title": "Carte créée par MCP", "body": "Corps de la carte", + }) + result, text = _result_text(r) + assert result["isError"] is False + created = json.loads(text) + assert created["title"] == "Carte créée par MCP" + assert created["repo_full_name"] == "Projets/demo" + assert created["column"] == "backlog" + assert created["number"] > 0 + # L'écriture est passée par le client de forge existant. + assert fake_gitea.created == [("Projets", "demo", "Carte créée par MCP")] + # La carte est placée sur le board (lecture MCP relue). + r = _call(client, "get_board", {"owner": "Projets", "repo": "demo"}) + board = json.loads(_result_text(r)[1]) + assert any( + c["title"] == "Carte créée par MCP" for c in board["backlog"] + ) + # Journal : création tracée avec la source « mcp ». + events = models.get_events(repo_full_name="Projets/demo", limit=10) + created_events = [ + e for e in events + if e["event_type"] == "created" + and e["issue_number"] == created["number"] + ] + assert created_events and created_events[0]["source"] == "mcp" + + +def test_mcp_create_issue_column_choice(client, fake_gitea): + r = _call(client, "create_issue", { + "owner": "Projets", "repo": "demo", + "title": "Directement à faire", "column": "todo", + }) + result, text = _result_text(r) + assert result["isError"] is False + assert json.loads(text)["column"] == "todo" + + +def test_mcp_create_issue_validation_errors(client, fake_gitea): + # Titre manquant : « Missing argument » (patron d'erreur du serveur). + r = _call(client, "create_issue", {"owner": "Projets", "repo": "demo"}) + result, text = _result_text(r) + assert result["isError"] is True + assert "Missing argument" in text + # Titre vide et colonne inconnue : erreurs métier en isError. + for arguments in ( + {"owner": "Projets", "repo": "demo", "title": " "}, + {"owner": "Projets", "repo": "demo", "title": "X", + "column": "nimporte"}, + ): + result, _text = _result_text(_call(client, "create_issue", arguments)) + assert result["isError"] is True + assert fake_gitea.created == [] + + +# ── Commentaire ──────────────────────────────────────────── + +def test_mcp_add_comment_posts_on_forge(client, fake_gitea): + r = _call(client, "add_comment", { + "owner": "Projets", "repo": "demo", "number": 1, + "body": "Commentaire du service d'agents", + }) + result, text = _result_text(r) + assert result["isError"] is False + payload = json.loads(text) + assert payload["number"] == 1 + assert payload["repo_full_name"] == "Projets/demo" + assert payload["body"] == "Commentaire du service d'agents" + assert fake_gitea.posted_comments == [ + ("Projets", "demo", 1, "Commentaire du service d'agents") + ] + events = models.get_events(repo_full_name="Projets/demo", limit=10) + commented = [e for e in events if e["event_type"] == "commented"] + assert commented and commented[0]["source"] == "mcp" + + +def test_mcp_add_comment_validation_errors(client, fake_gitea): + r = _call(client, "add_comment", { + "owner": "Projets", "repo": "demo", "number": 1, "body": " ", + }) + assert _result_text(r)[0]["isError"] is True + r = _call(client, "add_comment", { + "owner": "Projets", "repo": "demo", "body": "sans numéro", + }) + result, text = _result_text(r) + assert result["isError"] is True + assert "Missing argument" in text + assert fake_gitea.posted_comments == [] + + +def test_mcp_write_tools_are_listed(client): + r = client.post("/mcp", json={ + "jsonrpc": "2.0", "id": 1, "method": "tools/list", + }) + tools = {t["name"]: t for t in r.json()["result"]["tools"]} + assert tools["create_issue"]["inputSchema"]["required"] == [ + "owner", "repo", "title", + ] + assert tools["add_comment"]["inputSchema"]["required"] == [ + "owner", "repo", "number", "body", + ] + # Aucun outil destructeur n'apparaît. + assert not any( + name.startswith(("delete", "remove", "close")) + for name in tools + ) + + +# ── Garde par jeton kbt_ (auth active) ───────────────────── + +@pytest.fixture() +def utilisateur(): + # Base de test partagée : nom unique, nettoyage complet au + # teardown (le pont SSO a une sémantique « un seul compte »). + uid = models.create_user( + f"mcp-{uuid.uuid4().hex[:8]}", + accounts.hash_password("motdepasse"), role="member") + user = models.get_user(uid) + yield user + with models.get_db() as conn: + conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,)) + conn.execute("DELETE FROM users WHERE id = ?", (uid,)) + + +def test_mcp_writes_guarded_by_api_token(monkeypatch, fake_gitea, utilisateur): + monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno") + monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse") + _row, clair = models.create_api_token(utilisateur["id"], "service-agents") + jeton = {"Authorization": f"Bearer {clair}"} + create_args = { + "owner": "Projets", "repo": "demo", "title": "Carte par jeton", + } + with TestClient(main.app) as client: + # Sans jeton : refusé (401), aucune écriture sur la forge. + assert _call(client, "create_issue", create_args).status_code == 401 + # Jeton inconnu : refusé aussi. + faux = {"Authorization": "Bearer kbt_inconnu"} + assert _call( + client, "create_issue", create_args, headers=faux + ).status_code == 401 + assert fake_gitea.created == [] + # Jeton valide : création et commentaire passent. + r = _call(client, "create_issue", create_args, headers=jeton) + assert r.status_code == 200 + assert _result_text(r)[0]["isError"] is False + assert fake_gitea.created == [("Projets", "demo", "Carte par jeton")] + r = _call(client, "add_comment", { + "owner": "Projets", "repo": "demo", "number": 1, + "body": "Vu par le service d'agents", + }, headers=jeton) + assert _result_text(r)[0]["isError"] is False + # Le même jeton n'ouvre jamais l'interface web. + assert client.get( + "/", headers={**jeton, "Accept": "application/json"} + ).status_code == 401 + # Révocation : effet immédiat sur les écritures. + assert models.revoke_api_token(utilisateur["id"], _row["id"]) is True + assert _call( + client, "create_issue", create_args, headers=jeton + ).status_code == 401 + assert _call(client, "add_comment", { + "owner": "Projets", "repo": "demo", "number": 1, "body": "x", + }, headers=jeton).status_code == 401 diff --git a/tests/test_palier3.py b/tests/test_palier3.py index 7185301..3dbfd0f 100644 --- a/tests/test_palier3.py +++ b/tests/test_palier3.py @@ -121,6 +121,7 @@ def test_mcp_initialize_and_tools_list(client): assert names == [ "list_projects", "get_board", "get_issue", "get_focus", "search_issues", "get_activity", "get_metrics", + "create_issue", "add_comment", ] @@ -153,7 +154,7 @@ def test_mcp_unknown_method_and_tool(client): assert r.json()["error"]["code"] == -32601 r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}}) result = r.json()["result"] - assert result["isError"] is True # read-only server: no such tool + assert result["isError"] is True # aucun outil de ce nom (pas de suppression) def test_mcp_notification_gets_202(client): @@ -164,7 +165,10 @@ def test_mcp_notification_gets_202(client): def test_mcp_info_endpoint(client): r = client.get("/mcp") assert r.status_code == 200 - assert r.json()["read_only"] is True + # 0.26.0 : écritures minimales ajoutées — le serveur n'est + # plus en lecture seule, et il le déclare honnêtement. + assert r.json()["read_only"] is False + assert r.json()["write_tools"] == ["create_issue", "add_comment"] # ── Board : gestion des étiquettes ────────────────────────