feat(palier-3): recherche globale, mes issues, briefing ntfy, MCP lecture
- client: search_issues (/repos/issues/search, type=issues, assigned_by), delete_label + forget_label_cache; models: kv_store - /search groupe par projet avec colonne Kanban; /my = issues ouvertes assignées à l'utilisateur du jeton (get_user) - briefing: _briefing_data (focus, âgées, milestones <= 7 j, activité journal), page /briefing, /api/briefing, envoi ntfy manuel + planificateur lifespan à KANBANTE_BRIEFING_TIME (kv briefing_last_sent, pas de doublon); app/briefing.py = format texte + envoi ntfy (en-têtes ASCII) - MCP: POST /mcp JSON-RPC (initialize, tools/list, tools/call, ping, notifications 202), 7 outils en lecture seule, GET /mcp de découverte; erreurs -32601/-32700, isError pour les outils - focus refactoré en _collect_focus_groups réutilisé par le briefing et l'outil MCP get_focus
This commit is contained in:
+577
-5
@@ -34,11 +34,13 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import APP_VERSION, get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
||||
from app.models import (
|
||||
KANBAN_COLUMNS,
|
||||
column_entry_times,
|
||||
get_board_state,
|
||||
get_events,
|
||||
get_kv,
|
||||
get_metrics,
|
||||
get_notes,
|
||||
get_stats,
|
||||
@@ -48,6 +50,7 @@ from app.models import (
|
||||
ping_db,
|
||||
reconcile_board_state,
|
||||
set_column_order,
|
||||
set_kv,
|
||||
set_notes,
|
||||
)
|
||||
|
||||
@@ -86,6 +89,33 @@ EVENT_ICONS = {
|
||||
|
||||
# ── App setup ──────────────────────────────────────────────
|
||||
|
||||
async def _briefing_scheduler() -> None:
|
||||
"""Send the daily briefing to ntfy at KANBANTE_BRIEFING_TIME.
|
||||
|
||||
Checks once a minute; the kv store remembers the last day sent so
|
||||
restarts never double-send.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
settings = get_settings()
|
||||
if settings.briefing_time and ntfy_configured(settings):
|
||||
now = datetime.now()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
if (
|
||||
now.strftime("%H:%M") >= settings.briefing_time
|
||||
and get_kv("briefing_last_sent") != today
|
||||
):
|
||||
data = await _briefing_data()
|
||||
if await send_ntfy(settings, format_briefing_text(data)):
|
||||
set_kv("briefing_last_sent", today)
|
||||
logger.info("Daily briefing sent to ntfy (%s)", today)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # never let the scheduler die silently
|
||||
logger.warning("Briefing scheduler error: %s", exc)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
@@ -102,7 +132,9 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(
|
||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||
)
|
||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||
yield
|
||||
scheduler.cancel()
|
||||
await gitea.aclose()
|
||||
|
||||
|
||||
@@ -471,9 +503,8 @@ def _card_ages(
|
||||
return ages
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
async def focus(request: Request):
|
||||
"""Cross-project view: everything in progress or in review."""
|
||||
async def _collect_focus_groups() -> list[dict]:
|
||||
"""In-progress / in-review cards across all active repos."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
active = active[:100]
|
||||
@@ -500,15 +531,232 @@ async def focus(request: Request):
|
||||
]
|
||||
if cards:
|
||||
groups.append({"full_name": full_name, "cards": cards})
|
||||
return groups
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
async def focus(request: Request):
|
||||
"""Cross-project view: everything in progress or in review."""
|
||||
return render(
|
||||
request,
|
||||
"focus.html",
|
||||
groups=groups,
|
||||
groups=await _collect_focus_groups(),
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
)
|
||||
|
||||
|
||||
def _group_issue_results(issues: list[dict]) -> list[dict]:
|
||||
"""Group search results by repo, annotated with the board column."""
|
||||
by_repo: dict[str, list[dict]] = {}
|
||||
for issue in issues:
|
||||
full_name = (issue.get("repository") or {}).get("full_name", "?")
|
||||
by_repo.setdefault(full_name, []).append(issue)
|
||||
groups = []
|
||||
for full_name in sorted(by_repo):
|
||||
state = get_board_state(full_name) if "/" in full_name else {}
|
||||
column_of = {
|
||||
card["issue_number"]: column
|
||||
for column, cards in state.items()
|
||||
for card in cards
|
||||
}
|
||||
rows = []
|
||||
for issue in by_repo[full_name]:
|
||||
if issue.get("state") == "open":
|
||||
column = column_of.get(issue["number"], "backlog")
|
||||
else:
|
||||
column = "done"
|
||||
rows.append({"issue": issue, "column": column})
|
||||
groups.append({"full_name": full_name, "rows": rows})
|
||||
return groups
|
||||
|
||||
|
||||
@app.get("/search", response_class=HTMLResponse)
|
||||
async def search_page(request: Request, q: str = ""):
|
||||
"""Global issue search across every repo (Gitea search API)."""
|
||||
q = q.strip()
|
||||
groups: list[dict] = []
|
||||
total = 0
|
||||
if q:
|
||||
results = await gitea.search_issues(q=q, state="all")
|
||||
total = len(results)
|
||||
groups = _group_issue_results(results)
|
||||
return render(
|
||||
request,
|
||||
"search.html",
|
||||
q=q,
|
||||
groups=groups,
|
||||
total=total,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/my", response_class=HTMLResponse)
|
||||
async def my_issues(request: Request):
|
||||
"""Open issues assigned to the Gitea token's owner, all repos."""
|
||||
user = None
|
||||
groups: list[dict] = []
|
||||
total = 0
|
||||
try:
|
||||
user = await gitea.get_user()
|
||||
except Exception as exc:
|
||||
logger.debug("get_user failed for /my: %s", exc)
|
||||
if user and user.get("login"):
|
||||
results = await gitea.search_issues(
|
||||
state="open", assigned_by=user["login"]
|
||||
)
|
||||
total = len(results)
|
||||
groups = _group_issue_results(results)
|
||||
return render(
|
||||
request,
|
||||
"my.html",
|
||||
user=user,
|
||||
groups=groups,
|
||||
total=total,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
)
|
||||
|
||||
|
||||
# ── Briefing ───────────────────────────────────────────────
|
||||
|
||||
async def _briefing_data() -> dict:
|
||||
"""Everything the daily briefing needs, gathered in one dict."""
|
||||
settings = get_settings()
|
||||
focus_groups = await _collect_focus_groups()
|
||||
|
||||
repos = await gitea.search_repos()
|
||||
active, _i, _a = classify_repos(repos)
|
||||
soon = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
aging: list[dict] = []
|
||||
milestones_due: list[dict] = []
|
||||
|
||||
async def _per_repo(repo: dict):
|
||||
full_name = repo["full_name"]
|
||||
owner, _, name = full_name.partition("/")
|
||||
items = await gitea.get_repo_issues(owner, name, state="open")
|
||||
open_issues = [i for i in items if _is_issue(i)]
|
||||
ages = _card_ages(full_name, open_issues, get_board_state(full_name))
|
||||
board = get_board_state(full_name)
|
||||
column_of = {
|
||||
card["issue_number"]: column
|
||||
for column, cards in board.items()
|
||||
for card in cards
|
||||
}
|
||||
repo_aging = [
|
||||
{
|
||||
"repo": full_name,
|
||||
"number": issue["number"],
|
||||
"title": issue["title"],
|
||||
"days": ages[issue["number"]]["days"],
|
||||
"column_label": COLUMN_LABELS[
|
||||
column_of.get(issue["number"], "backlog")
|
||||
],
|
||||
}
|
||||
for issue in open_issues
|
||||
if issue["number"] in ages
|
||||
and ages[issue["number"]]["days"] >= settings.aging_days
|
||||
]
|
||||
repo_milestones = []
|
||||
try:
|
||||
for ms in await gitea.get_milestones(owner, name):
|
||||
due = _parse_datetime(ms.get("due_on"))
|
||||
if due and due <= soon and (ms.get("open_issues") or 0) > 0:
|
||||
repo_milestones.append(
|
||||
{
|
||||
"repo": full_name,
|
||||
"title": ms.get("title", ""),
|
||||
"due_on": str(ms["due_on"])[:10],
|
||||
"open": ms.get("open_issues") or 0,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("briefing milestones failed for %s: %s", full_name, exc)
|
||||
return repo_aging, repo_milestones
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(_per_repo(r) for r in active[:100]), return_exceptions=True
|
||||
)
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.warning("briefing: repo fetch failed: %s", result)
|
||||
continue
|
||||
repo_aging, repo_milestones = result
|
||||
aging.extend(repo_aging)
|
||||
milestones_due.extend(repo_milestones)
|
||||
aging.sort(key=lambda c: -c["days"])
|
||||
|
||||
metrics_24h = get_metrics(window_days=1)
|
||||
metrics_30d = get_metrics(window_days=30)
|
||||
return {
|
||||
"date": datetime.now().strftime("%d/%m/%Y"),
|
||||
"focus_total": sum(len(g["cards"]) for g in focus_groups),
|
||||
"focus_groups": [
|
||||
{
|
||||
"full_name": g["full_name"],
|
||||
"count": len(g["cards"]),
|
||||
"titles": [c["issue"]["title"] for c in g["cards"]],
|
||||
}
|
||||
for g in focus_groups
|
||||
],
|
||||
"aging": aging[:10],
|
||||
"milestones_due": milestones_due[:5],
|
||||
"activity_24h": {
|
||||
**metrics_24h,
|
||||
"avg_cycle_days": metrics_30d["avg_cycle_days"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/briefing", response_class=HTMLResponse)
|
||||
async def briefing_page(request: Request):
|
||||
"""The daily briefing, readable on the web before it is pushed."""
|
||||
settings = get_settings()
|
||||
data = await _briefing_data()
|
||||
return render(
|
||||
request,
|
||||
"briefing.html",
|
||||
data=data,
|
||||
text=format_briefing_text(data),
|
||||
ntfy_configured=ntfy_configured(settings),
|
||||
briefing_time=settings.briefing_time,
|
||||
last_sent=get_kv("briefing_last_sent"),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/briefing")
|
||||
async def api_briefing():
|
||||
data = await _briefing_data()
|
||||
return {"ok": True, "briefing": data, "text": format_briefing_text(data)}
|
||||
|
||||
|
||||
@app.post("/api/briefing/send")
|
||||
async def api_briefing_send(request: Request, csrf_token: str = Form("")):
|
||||
"""Send the briefing to the configured ntfy topic, right now."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
settings = get_settings()
|
||||
if not ntfy_configured(settings):
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "ntfy is not configured "
|
||||
"(KANBANTE_NTFY_URL / KANBANTE_NTFY_TOPIC)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
data = await _briefing_data()
|
||||
sent = await send_ntfy(settings, format_briefing_text(data))
|
||||
if not sent:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "ntfy server rejected or unreachable"},
|
||||
status_code=502,
|
||||
)
|
||||
set_kv("briefing_last_sent", datetime.now().strftime("%Y-%m-%d"))
|
||||
return {"ok": True, "sent": True}
|
||||
|
||||
|
||||
# ── Webhooks + live stream ─────────────────────────────────
|
||||
|
||||
def _apply_external_transition(
|
||||
@@ -802,7 +1050,7 @@ def _issue_payload(issue: dict) -> dict:
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"due_date": issue.get("due_date"),
|
||||
"labels": [
|
||||
{"name": l.get("name", ""), "color": l.get("color", "")}
|
||||
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||
for l in issue.get("labels") or []
|
||||
],
|
||||
"assignees": [
|
||||
@@ -889,6 +1137,330 @@ async def api_metrics(repo: str = "", window: int = 30):
|
||||
return {"ok": True, "metrics": get_metrics(repo or None, window)}
|
||||
|
||||
|
||||
# ── Label management ───────────────────────────────────────
|
||||
|
||||
@app.get("/api/repos/{owner}/{repo}/labels")
|
||||
async def api_list_labels(owner: str, repo: str):
|
||||
"""All label definitions of a repo."""
|
||||
labels = await gitea.get_labels(owner, repo)
|
||||
return {
|
||||
"ok": True,
|
||||
"labels": [
|
||||
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||
for l in labels
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/repos/{owner}/{repo}/labels")
|
||||
async def api_create_label(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
name: str = Form(...),
|
||||
color: str = Form("7c5cfc"),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Create a label definition in the repo."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
name = name.strip()
|
||||
color = color.strip().lstrip("#")
|
||||
if not name or len(name) > 50:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Label name must be 1-50 chars"},
|
||||
status_code=400,
|
||||
)
|
||||
if len(color) != 6 or any(c not in "0123456789abcdefABCDEF" for c in color):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Color must be a 6-digit hex value"},
|
||||
status_code=400,
|
||||
)
|
||||
label = await gitea.create_label(owner, repo, name, color)
|
||||
gitea.forget_label_cache(owner, repo)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"label": {
|
||||
"id": label.get("id"),
|
||||
"name": label.get("name", name),
|
||||
"color": label.get("color", color),
|
||||
},
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/repos/{owner}/{repo}/labels/{label_id}")
|
||||
async def api_delete_label(
|
||||
request: Request, owner: str, repo: str, label_id: int
|
||||
):
|
||||
"""Delete a label definition (CSRF via the X-CSRF-Token header)."""
|
||||
if not _csrf_valid(request, ""):
|
||||
return _csrf_error()
|
||||
await gitea.delete_label(owner, repo, label_id)
|
||||
return {"ok": True, "deleted": label_id}
|
||||
|
||||
|
||||
@app.post("/api/issues/{owner}/{repo}/{number}/labels")
|
||||
async def api_toggle_issue_label(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
label_id: int = Form(...),
|
||||
action: str = Form(...),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Add or remove one label on one issue; returns the new label set."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
if action not in ("add", "remove"):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "action must be 'add' or 'remove'"},
|
||||
status_code=400,
|
||||
)
|
||||
if action == "add":
|
||||
await gitea.add_issue_label(owner, repo, number, label_id)
|
||||
else:
|
||||
await gitea.remove_issue_label(owner, repo, number, label_id)
|
||||
issue = await gitea.get_issue(owner, repo, number)
|
||||
return {
|
||||
"ok": True,
|
||||
"labels": [
|
||||
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||
for l in issue.get("labels") or []
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── MCP server (read-only tools over JSON-RPC) ─────────────
|
||||
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "list_projects",
|
||||
"description": "List the projects (Gitea repos) Kanbante tracks, grouped by activity.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_board",
|
||||
"description": "Get the kanban board of one repo: columns with their cards.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_issue",
|
||||
"description": "Get one issue with its body and comments.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"number": {"type": "integer"},
|
||||
},
|
||||
"required": ["owner", "repo", "number"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_focus",
|
||||
"description": "Cards in progress or in review across all active projects.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "search_issues",
|
||||
"description": "Search issues by text across all repos.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"state": {"type": "string", "enum": ["open", "closed", "all"]},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_activity",
|
||||
"description": "Latest entries of the board event journal.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"limit": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_metrics",
|
||||
"description": "Throughput and cycle-time metrics from the journal.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"repo": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _mcp_call_tool(name: str, args: dict) -> str:
|
||||
"""Execute one read-only MCP tool and return text content."""
|
||||
if name == "list_projects":
|
||||
repos = await gitea.search_repos()
|
||||
active, inactive, archived = classify_repos(repos)
|
||||
lines = [f"Active projects ({len(active)}):"]
|
||||
lines += [
|
||||
f"- {r['full_name']} ({r.get('open_issues_count') or 0} open issues)"
|
||||
for r in active
|
||||
]
|
||||
lines.append(
|
||||
f"Inactive: {len(inactive)} - Archived: {len(archived)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
if name == "get_board":
|
||||
owner, repo = args["owner"], args["repo"]
|
||||
items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||
issues = [i for i in items if _is_issue(i)]
|
||||
open_issues = [i for i in issues if i.get("state") == "open"]
|
||||
columns = _merge_issues_with_board(open_issues, get_board_state(f"{owner}/{repo}"))
|
||||
return json.dumps(
|
||||
{
|
||||
column: [
|
||||
{
|
||||
"number": i["number"],
|
||||
"title": i["title"],
|
||||
"labels": [l.get("name") for l in i.get("labels") or []],
|
||||
}
|
||||
for i in cards
|
||||
]
|
||||
for column, cards in columns.items()
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if name == "get_issue":
|
||||
detail = await api_issue_detail(args["owner"], args["repo"], int(args["number"]))
|
||||
return json.dumps(detail, ensure_ascii=False, default=str)
|
||||
if name == "get_focus":
|
||||
groups = await _collect_focus_groups()
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"repo": g["full_name"],
|
||||
"cards": [
|
||||
{
|
||||
"number": c["issue"]["number"],
|
||||
"title": c["issue"]["title"],
|
||||
"column": c["column"],
|
||||
}
|
||||
for c in g["cards"]
|
||||
],
|
||||
}
|
||||
for g in groups
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if name == "search_issues":
|
||||
results = await gitea.search_issues(
|
||||
q=args["query"], state=args.get("state", "open")
|
||||
)
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"repo": (i.get("repository") or {}).get("full_name"),
|
||||
"number": i["number"],
|
||||
"title": i["title"],
|
||||
"state": i["state"],
|
||||
}
|
||||
for i in results
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if name == "get_activity":
|
||||
return json.dumps(
|
||||
get_events(limit=int(args.get("limit", 50))), ensure_ascii=False
|
||||
)
|
||||
if name == "get_metrics":
|
||||
return json.dumps(get_metrics(args.get("repo") or None), ensure_ascii=False)
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
@app.get("/mcp")
|
||||
async def mcp_info():
|
||||
"""Discoverability for the MCP endpoint (the protocol is POST)."""
|
||||
return {
|
||||
"name": "kanbante",
|
||||
"version": APP_VERSION,
|
||||
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
|
||||
"read_only": True,
|
||||
"tools": [t["name"] for t in MCP_TOOLS],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/mcp")
|
||||
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).
|
||||
"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": None,
|
||||
"error": {"code": -32700, "message": "Parse error"}},
|
||||
status_code=400,
|
||||
)
|
||||
if isinstance(payload, list): # batch: not supported by this server
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": None,
|
||||
"error": {"code": -32600, "message": "Batch requests not supported"}},
|
||||
status_code=400,
|
||||
)
|
||||
method = payload.get("method", "")
|
||||
rpc_id = payload.get("id")
|
||||
if rpc_id is None: # notification: acknowledge silently
|
||||
return Response(status_code=202)
|
||||
params = payload.get("params") or {}
|
||||
|
||||
def _result(result):
|
||||
return {"jsonrpc": "2.0", "id": rpc_id, "result": result}
|
||||
|
||||
def _error(code, message):
|
||||
return {"jsonrpc": "2.0", "id": rpc_id,
|
||||
"error": {"code": code, "message": message}}
|
||||
|
||||
if method == "initialize":
|
||||
return _result(
|
||||
{
|
||||
"protocolVersion": params.get("protocolVersion", "2024-11-05"),
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "kanbante", "version": APP_VERSION},
|
||||
}
|
||||
)
|
||||
if method == "ping":
|
||||
return _result({})
|
||||
if method == "tools/list":
|
||||
return _result({"tools": MCP_TOOLS})
|
||||
if method == "tools/call":
|
||||
tool_name = params.get("name", "")
|
||||
arguments = params.get("arguments") or {}
|
||||
try:
|
||||
text = await _mcp_call_tool(tool_name, arguments)
|
||||
except KeyError as exc:
|
||||
return _result(
|
||||
{"content": [{"type": "text", "text": f"Missing argument: {exc}"}],
|
||||
"isError": True}
|
||||
)
|
||||
except Exception as exc:
|
||||
return _result(
|
||||
{"content": [{"type": "text", "text": f"Tool error: {exc}"}],
|
||||
"isError": True}
|
||||
)
|
||||
return _result({"content": [{"type": "text", "text": text}], "isError": False})
|
||||
return _error(-32601, f"Method not found: {method}")
|
||||
|
||||
|
||||
@app.post("/api/notes")
|
||||
async def api_notes(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user