feat(0.26.0): écritures MCP minimales — create_issue + add_comment gardés par jeton kbt_
This commit is contained in:
+1
-1
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user