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
+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()