feat: dual-mode — MCP server and CLI skill in one file
Expose the same 66 Gitea tools two ways from server/gitea_mcp.py:
- MCP server (no subcommand -> mcp.run()), as before
- CLI: `<tool> --flags` prints one JSON object {success, data/error}
A new @tool decorator registers each function in both FastMCP and a TOOLS
registry; a CLI dispatcher builds argparse from each function's signature and
wraps results in the repo's JSON+success contract. Adds `list-tools` for
discovery. No tool logic duplicated.
- Rewrite skills/gitea/SKILL.md for CLI-first usage (MCP kept as an option)
- Sync version to 0.3.0 across plugin.json, pyproject.toml, SKILL.md
- Add tests/unit/test_cli.py (dispatch routing, flag typing, errors, main)
- Document dual-mode in README.md and CLAUDE.md
- Ignore coverage artifacts
Tests: 119 passed, 93% coverage.
This commit is contained in:
+233
-68
@@ -8,6 +8,9 @@ import base64
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import types
|
||||
import inspect
|
||||
import typing
|
||||
import argparse
|
||||
import httpx
|
||||
from typing import Any
|
||||
@@ -107,11 +110,27 @@ def DELETE(instance, path, body=None):
|
||||
|
||||
mcp = FastMCP("gitea")
|
||||
|
||||
# Single registry of every tool, keyed by name. Populated by the @tool decorator
|
||||
# below and consumed by the CLI dispatcher at the bottom of the file. This is what
|
||||
# lets one source file serve both as an MCP server and as a CLI-backed skill.
|
||||
TOOLS: dict[str, Any] = {}
|
||||
|
||||
|
||||
def tool(fn):
|
||||
"""Register a function as BOTH an MCP tool and a CLI subcommand.
|
||||
|
||||
The wrapped function stays directly callable (mcp.tool() returns the original
|
||||
function), so unit tests and the CLI dispatcher can invoke it without going
|
||||
through the MCP transport.
|
||||
"""
|
||||
TOOLS[fn.__name__] = fn
|
||||
return mcp.tool()(fn)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# META
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_instances() -> Any:
|
||||
"""List all configured Gitea instances and show which one is default."""
|
||||
return {
|
||||
@@ -119,12 +138,12 @@ def list_instances() -> Any:
|
||||
"default": DEFAULT_INSTANCE,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_server_info(instance: str = "") -> Any:
|
||||
"""Get Gitea server version and info."""
|
||||
return GET(instance, "/version")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_current_user(instance: str = "") -> Any:
|
||||
"""Get information about the authenticated user."""
|
||||
return GET(instance, "/user")
|
||||
@@ -133,22 +152,22 @@ def get_current_user(instance: str = "") -> Any:
|
||||
# REPOSITORIES
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_my_repos(limit: int = 50, page: int = 1, instance: str = "") -> Any:
|
||||
"""List repositories of the authenticated user."""
|
||||
return GET(instance, "/user/repos", {"limit": limit, "page": page})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def search_repos(query: str, limit: int = 20, page: int = 1, instance: str = "") -> Any:
|
||||
"""Search repositories across the Gitea instance."""
|
||||
return GET(instance, "/repos/search", {"q": query, "limit": limit, "page": page})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_repo(owner: str, repo: str, instance: str = "") -> Any:
|
||||
"""Get details of a repository."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_repo(
|
||||
name: str,
|
||||
description: str = "",
|
||||
@@ -170,7 +189,7 @@ def create_repo(
|
||||
"license": license or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_org_repo(
|
||||
org: str,
|
||||
name: str,
|
||||
@@ -189,7 +208,7 @@ def create_org_repo(
|
||||
"default_branch": default_branch,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def update_repo(
|
||||
owner: str, repo: str,
|
||||
description: str = "", private: bool | None = None, website: str = "",
|
||||
@@ -202,12 +221,12 @@ def update_repo(
|
||||
"website": website or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def delete_repo(owner: str, repo: str, instance: str = "") -> Any:
|
||||
"""Delete a repository permanently."""
|
||||
return DELETE(instance, f"/repos/{owner}/{repo}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def fork_repo(owner: str, repo: str, organization: str = "", new_repo_name: str = "", instance: str = "") -> Any:
|
||||
"""Fork a repository."""
|
||||
return POST(instance, f"/repos/{owner}/{repo}/forks", _strip({
|
||||
@@ -215,7 +234,7 @@ def fork_repo(owner: str, repo: str, organization: str = "", new_repo_name: str
|
||||
"name": new_repo_name or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def set_repo_topics(owner: str, repo: str, topics: list[str], instance: str = "") -> Any:
|
||||
"""Replace all topics of a repository."""
|
||||
return PUT(instance, f"/repos/{owner}/{repo}/topics", {"topics": topics})
|
||||
@@ -224,17 +243,17 @@ def set_repo_topics(owner: str, repo: str, topics: list[str], instance: str = ""
|
||||
# BRANCHES & TAGS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_branches(owner: str, repo: str, limit: int = 50, instance: str = "") -> Any:
|
||||
"""List all branches in a repository."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/branches", {"limit": limit})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_branch(owner: str, repo: str, branch: str, instance: str = "") -> Any:
|
||||
"""Get details of a branch including its latest commit."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/branches/{_b(branch)}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_branch(owner: str, repo: str, new_branch_name: str, old_branch_name: str = "", instance: str = "") -> Any:
|
||||
"""Create a branch. old_branch_name defaults to the repo's default branch."""
|
||||
return POST(instance, f"/repos/{owner}/{repo}/branches", _strip({
|
||||
@@ -242,12 +261,12 @@ def create_branch(owner: str, repo: str, new_branch_name: str, old_branch_name:
|
||||
"old_branch_name": old_branch_name or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def delete_branch(owner: str, repo: str, branch: str, instance: str = "") -> Any:
|
||||
"""Delete a branch."""
|
||||
return DELETE(instance, f"/repos/{owner}/{repo}/branches/{_b(branch)}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_tags(owner: str, repo: str, limit: int = 20, instance: str = "") -> Any:
|
||||
"""List tags in a repository."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/tags", {"limit": limit})
|
||||
@@ -256,17 +275,17 @@ def list_tags(owner: str, repo: str, limit: int = 20, instance: str = "") -> Any
|
||||
# FILES & CONTENTS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_directory(owner: str, repo: str, path: str = "", ref: str = "", instance: str = "") -> Any:
|
||||
"""List files and directories at a path. ref can be branch/tag/commit."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/contents/{path}", _strip({"ref": ref or None}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_file(owner: str, repo: str, filepath: str, ref: str = "", instance: str = "") -> Any:
|
||||
"""Get file content and metadata. Returns base64-encoded content + SHA."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/contents/{filepath}", _strip({"ref": ref or None}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_file(
|
||||
owner: str, repo: str, filepath: str,
|
||||
content: str, message: str,
|
||||
@@ -280,7 +299,7 @@ def create_file(
|
||||
"branch": branch or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def update_file(
|
||||
owner: str, repo: str, filepath: str,
|
||||
content: str, message: str, sha: str,
|
||||
@@ -295,7 +314,7 @@ def update_file(
|
||||
"branch": branch or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def delete_file(
|
||||
owner: str, repo: str, filepath: str,
|
||||
message: str, sha: str,
|
||||
@@ -310,7 +329,7 @@ def delete_file(
|
||||
# ISSUES
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_issues(
|
||||
owner: str, repo: str,
|
||||
state: str = "open", issue_type: str = "issues",
|
||||
@@ -325,12 +344,12 @@ def list_issues(
|
||||
"limit": limit, "page": page,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_issue(owner: str, repo: str, index: int, instance: str = "") -> Any:
|
||||
"""Get a single issue by number."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/issues/{index}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_issue(
|
||||
owner: str, repo: str, title: str, body: str = "",
|
||||
assignees: list[str] | None = None, labels: list[int] | None = None, milestone: int = 0,
|
||||
@@ -345,7 +364,7 @@ def create_issue(
|
||||
"milestone": milestone or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def update_issue(
|
||||
owner: str, repo: str, index: int,
|
||||
title: str = "", body: str = "", state: str = "",
|
||||
@@ -356,32 +375,32 @@ def update_issue(
|
||||
"title": title or None, "body": body or None, "state": state or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def close_issue(owner: str, repo: str, index: int, instance: str = "") -> Any:
|
||||
"""Close an issue."""
|
||||
return PATCH(instance, f"/repos/{owner}/{repo}/issues/{index}", {"state": "closed"})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def reopen_issue(owner: str, repo: str, index: int, instance: str = "") -> Any:
|
||||
"""Reopen a closed issue."""
|
||||
return PATCH(instance, f"/repos/{owner}/{repo}/issues/{index}", {"state": "open"})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_issue_comments(owner: str, repo: str, index: int, instance: str = "") -> Any:
|
||||
"""List comments on an issue."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/issues/{index}/comments")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def add_issue_comment(owner: str, repo: str, index: int, body: str, instance: str = "") -> Any:
|
||||
"""Add a comment to an issue or PR."""
|
||||
return POST(instance, f"/repos/{owner}/{repo}/issues/{index}/comments", {"body": body})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def edit_issue_comment(owner: str, repo: str, comment_id: int, body: str, instance: str = "") -> Any:
|
||||
"""Edit an existing comment."""
|
||||
return PATCH(instance, f"/repos/{owner}/{repo}/issues/comments/{comment_id}", {"body": body})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def delete_issue_comment(owner: str, repo: str, comment_id: int, instance: str = "") -> Any:
|
||||
"""Delete a comment."""
|
||||
return DELETE(instance, f"/repos/{owner}/{repo}/issues/comments/{comment_id}")
|
||||
@@ -390,29 +409,29 @@ def delete_issue_comment(owner: str, repo: str, comment_id: int, instance: str =
|
||||
# LABELS & MILESTONES
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_labels(owner: str, repo: str, instance: str = "") -> Any:
|
||||
"""List all labels in a repository."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/labels")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_label(owner: str, repo: str, name: str, color: str, description: str = "", instance: str = "") -> Any:
|
||||
"""Create a label. color is hex like #ff0000."""
|
||||
return POST(instance, f"/repos/{owner}/{repo}/labels", _strip({
|
||||
"name": name, "color": color, "description": description or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def add_issue_labels(owner: str, repo: str, index: int, label_ids: list[int], instance: str = "") -> Any:
|
||||
"""Add labels to an issue."""
|
||||
return POST(instance, f"/repos/{owner}/{repo}/issues/{index}/labels", {"labels": label_ids})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_milestones(owner: str, repo: str, state: str = "open", instance: str = "") -> Any:
|
||||
"""List milestones. state: open|closed|all."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/milestones", {"state": state})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_milestone(owner: str, repo: str, title: str, description: str = "", due_on: str = "", instance: str = "") -> Any:
|
||||
"""Create a milestone. due_on: 2024-12-31T00:00:00Z"""
|
||||
return POST(instance, f"/repos/{owner}/{repo}/milestones", _strip({
|
||||
@@ -423,17 +442,17 @@ def create_milestone(owner: str, repo: str, title: str, description: str = "", d
|
||||
# PULL REQUESTS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_pull_requests(owner: str, repo: str, state: str = "open", limit: int = 20, instance: str = "") -> Any:
|
||||
"""List pull requests. state: open|closed|all."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/pulls", {"state": state, "limit": limit})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_pull_request(owner: str, repo: str, index: int, instance: str = "") -> Any:
|
||||
"""Get a pull request by index."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/pulls/{index}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_pull_request(
|
||||
owner: str, repo: str, title: str, head: str, base: str,
|
||||
body: str = "", assignees: list[str] | None = None, labels: list[int] | None = None,
|
||||
@@ -447,7 +466,7 @@ def create_pull_request(
|
||||
"labels": labels or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def update_pull_request(
|
||||
owner: str, repo: str, index: int,
|
||||
title: str = "", body: str = "", state: str = "",
|
||||
@@ -458,7 +477,7 @@ def update_pull_request(
|
||||
"title": title or None, "body": body or None, "state": state or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def merge_pull_request(
|
||||
owner: str, repo: str, index: int,
|
||||
merge_style: str = "merge",
|
||||
@@ -474,7 +493,7 @@ def merge_pull_request(
|
||||
"delete_branch_after_merge": delete_branch_after_merge,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_pr_diff(owner: str, repo: str, index: int, instance: str = "") -> Any:
|
||||
"""Get the diff of a pull request as text."""
|
||||
api, headers = _get_instance(instance)
|
||||
@@ -484,7 +503,7 @@ def get_pr_diff(owner: str, repo: str, index: int, instance: str = "") -> Any:
|
||||
raise RuntimeError(f"Gitea API {r.status_code}: {r.text}")
|
||||
return {"diff": r.text}
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_pr_files(
|
||||
owner: str, repo: str, index: int,
|
||||
limit: int = 50, page: int = 1,
|
||||
@@ -497,12 +516,12 @@ def list_pr_files(
|
||||
"whitespace": whitespace or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_pr_reviews(owner: str, repo: str, index: int, instance: str = "") -> Any:
|
||||
"""List reviews on a pull request."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/pulls/{index}/reviews")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_pr_review(
|
||||
owner: str, repo: str, index: int,
|
||||
state: str, body: str = "",
|
||||
@@ -517,17 +536,17 @@ def create_pr_review(
|
||||
# RELEASES
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_releases(owner: str, repo: str, limit: int = 10, instance: str = "") -> Any:
|
||||
"""List releases."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/releases", {"limit": limit})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_latest_release(owner: str, repo: str, instance: str = "") -> Any:
|
||||
"""Get the latest release."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/releases/latest")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_release(
|
||||
owner: str, repo: str, tag_name: str, name: str,
|
||||
body: str = "", draft: bool = False, prerelease: bool = False,
|
||||
@@ -541,7 +560,7 @@ def create_release(
|
||||
"target_commitish": target_commitish or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def delete_release(owner: str, repo: str, release_id: int, instance: str = "") -> Any:
|
||||
"""Delete a release."""
|
||||
return DELETE(instance, f"/repos/{owner}/{repo}/releases/{release_id}")
|
||||
@@ -550,12 +569,12 @@ def delete_release(owner: str, repo: str, release_id: int, instance: str = "") -
|
||||
# WEBHOOKS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_repo_webhooks(owner: str, repo: str, instance: str = "") -> Any:
|
||||
"""List webhooks for a repository."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/hooks")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def create_repo_webhook(
|
||||
owner: str, repo: str, url: str, events: list[str],
|
||||
secret: str = "", active: bool = True,
|
||||
@@ -569,7 +588,7 @@ def create_repo_webhook(
|
||||
"active": active,
|
||||
})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def delete_repo_webhook(owner: str, repo: str, hook_id: int, instance: str = "") -> Any:
|
||||
"""Delete a webhook."""
|
||||
return DELETE(instance, f"/repos/{owner}/{repo}/hooks/{hook_id}")
|
||||
@@ -578,37 +597,37 @@ def delete_repo_webhook(owner: str, repo: str, hook_id: int, instance: str = "")
|
||||
# USERS & ORGANIZATIONS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_user(username: str, instance: str = "") -> Any:
|
||||
"""Get public information about a user."""
|
||||
return GET(instance, f"/users/{username}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def search_users(query: str, limit: int = 10, instance: str = "") -> Any:
|
||||
"""Search for users."""
|
||||
return GET(instance, "/users/search", {"q": query, "limit": limit})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_my_orgs(instance: str = "") -> Any:
|
||||
"""List organizations the authenticated user belongs to."""
|
||||
return GET(instance, "/user/orgs")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_org(org: str, instance: str = "") -> Any:
|
||||
"""Get information about an organization."""
|
||||
return GET(instance, f"/orgs/{org}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_org_repos(org: str, limit: int = 50, instance: str = "") -> Any:
|
||||
"""List repositories of an organization."""
|
||||
return GET(instance, f"/orgs/{org}/repos", {"limit": limit})
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_org_members(org: str, instance: str = "") -> Any:
|
||||
"""List members of an organization."""
|
||||
return GET(instance, f"/orgs/{org}/members")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_org_teams(org: str, instance: str = "") -> Any:
|
||||
"""List teams in an organization."""
|
||||
return GET(instance, f"/orgs/{org}/teams")
|
||||
@@ -617,7 +636,7 @@ def list_org_teams(org: str, instance: str = "") -> Any:
|
||||
# COMMITS & HISTORY
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_commits(
|
||||
owner: str, repo: str,
|
||||
sha: str = "", path: str = "",
|
||||
@@ -630,7 +649,7 @@ def list_commits(
|
||||
"limit": limit, "page": page,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def get_commit(
|
||||
owner: str, repo: str, sha: str,
|
||||
stat: bool = True, files: bool = True, verification: bool = False,
|
||||
@@ -641,12 +660,12 @@ def get_commit(
|
||||
"stat": stat, "files": files, "verification": verification or None,
|
||||
}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def compare_branches(owner: str, repo: str, base: str, head: str, instance: str = "") -> Any:
|
||||
"""Compare two branches, tags, or commits."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/compare/{_b(base)}...{_b(head)}")
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_repo_contributors(owner: str, repo: str, limit: int = 20, instance: str = "") -> Any:
|
||||
"""List contributors to a repository."""
|
||||
return GET(instance, f"/repos/{owner}/{repo}/contributors", {"limit": limit})
|
||||
@@ -655,17 +674,163 @@ def list_repo_contributors(owner: str, repo: str, limit: int = 20, instance: str
|
||||
# NOTIFICATIONS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def list_notifications(include_read: bool = False, limit: int = 20, instance: str = "") -> Any:
|
||||
"""List notifications. include_read=True includes already-read notifications."""
|
||||
return GET(instance, "/notifications", _strip({"all": include_read, "limit": limit}))
|
||||
|
||||
@mcp.tool()
|
||||
@tool
|
||||
def mark_all_notifications_read(instance: str = "") -> Any:
|
||||
"""Mark all notifications as read."""
|
||||
return PUT(instance, "/notifications", {})
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# CLI DISPATCHER (the second face of the same tools)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# The same tool functions are exposed two ways from this one file:
|
||||
# • MCP server — no subcommand → mcp.run() (for MCP-aware clients)
|
||||
# • CLI — `<tool> --args` → one JSON line stdout (for Cline-style skills)
|
||||
#
|
||||
# CLI output contract: exactly ONE JSON object on stdout. Tool-level errors are
|
||||
# JSON too ({"success": false, "error": ...}); the process prints and exits 0
|
||||
# rather than crashing — the caller checks the `success` field, not the exit code.
|
||||
# Hard startup failures (missing/invalid config) still exit non-zero, before this.
|
||||
|
||||
|
||||
class _CliError(Exception):
|
||||
"""Raised instead of argparse's SystemExit so we can emit a JSON envelope."""
|
||||
|
||||
|
||||
class _ArgParser(argparse.ArgumentParser):
|
||||
def error(self, message): # noqa: D102 — argparse override
|
||||
raise _CliError(message)
|
||||
|
||||
|
||||
def _arg_spec(annotation):
|
||||
"""Map a parameter annotation to (kind, element_type) for argparse.
|
||||
|
||||
kind ∈ {"str", "int", "bool", "list"}. Unions like `bool | None` and
|
||||
`list[str] | None` are unwrapped to their non-None inner type.
|
||||
"""
|
||||
origin = typing.get_origin(annotation)
|
||||
if origin in (list, typing.List):
|
||||
elem = (typing.get_args(annotation) or (str,))[0]
|
||||
return "list", (int if elem is int else str)
|
||||
if isinstance(annotation, type) and issubclass(annotation, bool):
|
||||
return "bool", bool
|
||||
if annotation is int:
|
||||
return "int", int
|
||||
if origin is typing.Union or isinstance(annotation, types.UnionType):
|
||||
non_none = [a for a in typing.get_args(annotation) if a is not type(None)]
|
||||
if non_none:
|
||||
inner = non_none[0]
|
||||
if inner is bool:
|
||||
return "bool", bool
|
||||
if typing.get_origin(inner) in (list, typing.List):
|
||||
elem = (typing.get_args(inner) or (str,))[0]
|
||||
return "list", (int if elem is int else str)
|
||||
return "str", str
|
||||
|
||||
|
||||
def _build_parser(name, fn):
|
||||
"""Build an argparse parser for a tool from its signature."""
|
||||
sig = inspect.signature(fn)
|
||||
parser = _ArgParser(
|
||||
prog=f"gitea {name}",
|
||||
description=(fn.__doc__ or "").strip(),
|
||||
add_help=False,
|
||||
)
|
||||
for pname, param in sig.parameters.items():
|
||||
kind, etype = _arg_spec(param.annotation)
|
||||
required = param.default is inspect.Parameter.empty
|
||||
opt = "--" + pname.replace("_", "-")
|
||||
if kind == "bool":
|
||||
parser.add_argument(
|
||||
opt, dest=pname, action=argparse.BooleanOptionalAction,
|
||||
default=None, required=required,
|
||||
)
|
||||
elif kind == "list":
|
||||
parser.add_argument(opt, dest=pname, nargs="*", type=etype, default=None, required=required)
|
||||
elif kind == "int":
|
||||
parser.add_argument(opt, dest=pname, type=int, default=None, required=required)
|
||||
else:
|
||||
parser.add_argument(opt, dest=pname, type=str, default=None, required=required)
|
||||
return parser, sig
|
||||
|
||||
|
||||
def _tool_list_payload():
|
||||
"""Self-describing catalogue of every tool — for skill/agent discovery."""
|
||||
tools = []
|
||||
for name, fn in sorted(TOOLS.items()):
|
||||
params = []
|
||||
for pname, param in inspect.signature(fn).parameters.items():
|
||||
kind, _ = _arg_spec(param.annotation)
|
||||
params.append({
|
||||
"name": pname,
|
||||
"type": kind,
|
||||
"required": param.default is inspect.Parameter.empty,
|
||||
})
|
||||
tools.append({
|
||||
"name": name,
|
||||
"description": (fn.__doc__ or "").strip(),
|
||||
"params": params,
|
||||
})
|
||||
return {"success": True, "data": {"count": len(tools), "tools": tools}}
|
||||
|
||||
|
||||
def dispatch(argv):
|
||||
"""Run one CLI subcommand.
|
||||
|
||||
Returns an envelope dict to print, or None to signal "start the MCP server".
|
||||
"""
|
||||
if not argv or argv[0] in ("serve", "--serve"):
|
||||
return None
|
||||
cmd, rest = argv[0], argv[1:]
|
||||
if cmd in ("list-tools", "--list-tools", "tools"):
|
||||
return _tool_list_payload()
|
||||
if cmd not in TOOLS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Unknown command '{cmd}'. Run 'list-tools' to see all {len(TOOLS)} tools.",
|
||||
"type": "UnknownCommand",
|
||||
}
|
||||
fn = TOOLS[cmd]
|
||||
try:
|
||||
parser, sig = _build_parser(cmd, fn)
|
||||
ns = parser.parse_args(rest)
|
||||
kwargs = {p: getattr(ns, p) for p in sig.parameters if getattr(ns, p) is not None}
|
||||
return {"success": True, "data": fn(**kwargs)}
|
||||
except _CliError as e:
|
||||
return {"success": False, "error": str(e), "type": "UsageError"}
|
||||
except Exception as e: # tool-level error → JSON, not a crash
|
||||
return {"success": False, "error": str(e), "type": type(e).__name__}
|
||||
|
||||
|
||||
def _drop_config(argv):
|
||||
"""Strip --config X / --config=X (already consumed by find_config) from argv."""
|
||||
out, skip = [], False
|
||||
for a in argv:
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if a == "--config":
|
||||
skip = True
|
||||
continue
|
||||
if a.startswith("--config="):
|
||||
continue
|
||||
out.append(a)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
envelope = dispatch(_drop_config(sys.argv[1:]))
|
||||
if envelope is None:
|
||||
mcp.run()
|
||||
return
|
||||
print(json.dumps(envelope, ensure_ascii=False, default=str))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user