feat(0.26.0): écritures MCP minimales — create_issue + add_comment gardés par jeton kbt_

This commit is contained in:
Spark
2026-10-22 03:31:19 +00:00
parent 13182f5d53
commit 5ef6c21756
7 changed files with 385 additions and 38 deletions
+35
View File
@@ -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)
+13 -7
View File
@@ -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)
+1 -1
View File
@@ -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:
+127 -27
View File
@@ -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()
+1 -1
View File
@@ -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 = [
+202
View File
@@ -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
+6 -2
View File
@@ -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 ────────────────────────