From 3a701a5280d197c0bd42d90b174dc695244a67c4 Mon Sep 17 00:00:00 2001 From: sHa Date: Fri, 20 Mar 2026 14:09:28 +0200 Subject: [PATCH] refactor, add modularity --- README.md | 8 +- app/__init__.py | 4 + app/client.py | 26 ++++ app/config.py | 11 ++ app/entities/__init__.py | 3 + app/entities/comments.py | 37 ++++++ app/entities/issues.py | 134 +++++++++++++++++++ app/entities/members.py | 41 ++++++ app/entities/search.py | 67 ++++++++++ app/entities/statuses.py | 13 ++ app/mcp.py | 3 + main.py | 9 +- server.py | 271 +-------------------------------------- 13 files changed, 354 insertions(+), 273 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/client.py create mode 100644 app/config.py create mode 100644 app/entities/__init__.py create mode 100644 app/entities/comments.py create mode 100644 app/entities/issues.py create mode 100644 app/entities/members.py create mode 100644 app/entities/search.py create mode 100644 app/entities/statuses.py create mode 100644 app/mcp.py diff --git a/README.md b/README.md index df0e250..a8e8696 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,16 @@ Exposes Redmine REST API as tools for Claude Code via MCP (Model Context Protoco | Tool | Description | |------|-------------| | `get_issue` | Get issue details by ID | -| `get_issue_statuses` | List all available statuses (use to find status IDs) | +| `list_issues` | Query issues with filters (project, status, assignee) | +| `update_issue` | Update title, description, priority, due date | | `update_issue_status` | Change issue status | | `update_issue_progress` | Set % done (0–100) | | `get_issue_comments` | Get all comments and field changes | | `add_issue_comment` | Post a new comment | | `assign_issue` | Assign issue to a user | | `get_project_members` | List project members with user IDs | -| `list_issues` | Query issues with filters (project, status, assignee) | -| `update_issue` | Update title, description, priority, due date | +| `get_issue_statuses` | List all available statuses (use to find status IDs) | +| `search` | Search by keyword across issues, wiki, news, documents, and more | ## Setup @@ -64,6 +65,7 @@ claude mcp list Once registered, just ask Claude naturally in any project: > "Get issue #1234" +> "Search for issues related to login bug" > "Change status of issue #1234 to In Progress" > "Add a comment to issue #1234: deployment done" > "Who is assigned to issue #1234?" diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..c63b9b0 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,4 @@ +from .mcp import mcp +from . import entities + +__all__ = ["mcp", "entities"] diff --git a/app/client.py b/app/client.py new file mode 100644 index 0000000..3046732 --- /dev/null +++ b/app/client.py @@ -0,0 +1,26 @@ +import json +import httpx +from .config import REDMINE_URL, REDMINE_API_KEY + + +def _headers() -> dict: + return { + "X-Redmine-API-Key": REDMINE_API_KEY, + "Content-Type": "application/json", + } + + +def _get(path: str, params: dict | None = None) -> dict: + url = f"{REDMINE_URL}{path}" + with httpx.Client(timeout=15) as client: + resp = client.get(url, headers=_headers(), params=params or {}) + resp.raise_for_status() + return resp.json() + + +def _put(path: str, body: dict) -> dict: + url = f"{REDMINE_URL}{path}" + with httpx.Client(timeout=15) as client: + resp = client.put(url, headers=_headers(), content=json.dumps(body)) + resp.raise_for_status() + return resp.json() if resp.content else {} diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..43b1de9 --- /dev/null +++ b/app/config.py @@ -0,0 +1,11 @@ +import os + +REDMINE_URL = os.environ.get("REDMINE_URL", "").rstrip("/") +REDMINE_API_KEY = os.environ.get("REDMINE_API_KEY", "") + + +def validate(): + if not REDMINE_URL or not REDMINE_API_KEY: + raise RuntimeError( + "REDMINE_URL and REDMINE_API_KEY environment variables must be set." + ) diff --git a/app/entities/__init__.py b/app/entities/__init__.py new file mode 100644 index 0000000..e3da70c --- /dev/null +++ b/app/entities/__init__.py @@ -0,0 +1,3 @@ +from . import issues, comments, members, statuses, search + +__all__ = ["issues", "comments", "members", "statuses", "search"] diff --git a/app/entities/comments.py b/app/entities/comments.py new file mode 100644 index 0000000..b65f813 --- /dev/null +++ b/app/entities/comments.py @@ -0,0 +1,37 @@ +import json +from ..mcp import mcp +from ..client import _get, _put + + +@mcp.tool() +def get_issue_comments(issue_id: int) -> str: + """ + Get all comments (journal notes) for a Redmine issue. + Returns list of comments with author, date, and text. + """ + data = _get(f"/issues/{issue_id}.json", params={"include": "journals"}) + journals = data["issue"].get("journals", []) + comments = [ + { + "id": j["id"], + "author": j["user"]["name"], + "created_on": j["created_on"], + "notes": j.get("notes", ""), + "details": j.get("details", []), + } + for j in journals + ] + return json.dumps(comments, ensure_ascii=False, indent=2) + + +@mcp.tool() +def add_issue_comment(issue_id: int, comment: str) -> str: + """ + Add a comment (journal note) to a Redmine issue. + + Args: + issue_id: Redmine issue ID + comment: Text of the comment to add + """ + _put(f"/issues/{issue_id}.json", {"issue": {"notes": comment}}) + return f"Comment added to issue #{issue_id}." diff --git a/app/entities/issues.py b/app/entities/issues.py new file mode 100644 index 0000000..0d9fa60 --- /dev/null +++ b/app/entities/issues.py @@ -0,0 +1,134 @@ +import json +from ..mcp import mcp +from ..client import _get, _put + + +@mcp.tool() +def get_issue(issue_id: int) -> str: + """ + Get a Redmine issue by ID. + Returns full issue details: title, description, status, priority, + assignee, progress, dates, custom fields. + """ + data = _get(f"/issues/{issue_id}.json") + return json.dumps(data["issue"], ensure_ascii=False, indent=2) + + +@mcp.tool() +def list_issues( + project_id: str = "", + status_id: str = "open", + assigned_to_id: str = "", + limit: int = 25, + offset: int = 0, +) -> str: + """ + List Redmine issues with optional filters. + + Args: + project_id: Filter by project ID or slug (empty = all projects) + status_id: "open", "closed", "*" (all), or a numeric status ID + assigned_to_id: Numeric user ID, or "me" for the API key owner + limit: Max results (1–100, default 25) + offset: Pagination offset + """ + params: dict = { + "status_id": status_id, + "limit": min(limit, 100), + "offset": offset, + } + if assigned_to_id: + params["assigned_to_id"] = assigned_to_id + + path = f"/projects/{project_id}/issues.json" if project_id else "/issues.json" + data = _get(path, params=params) + + issues = [ + { + "id": i["id"], + "subject": i["subject"], + "status": i["status"]["name"], + "priority": i["priority"]["name"], + "assigned_to": i.get("assigned_to", {}).get("name", "—"), + "done_ratio": i.get("done_ratio", 0), + "updated_on": i.get("updated_on", ""), + } + for i in data.get("issues", []) + ] + total = data.get("total_count", len(issues)) + return json.dumps({"total": total, "issues": issues}, ensure_ascii=False, indent=2) + + +@mcp.tool() +def update_issue( + issue_id: int, + subject: str = "", + description: str = "", + priority_id: int = 0, + due_date: str = "", + comment: str = "", +) -> str: + """ + Update general fields of a Redmine issue. + + Args: + issue_id: Redmine issue ID + subject: New title (leave empty to keep current) + description: New description (leave empty to keep current) + priority_id: Priority ID (leave 0 to keep current) + due_date: Due date in YYYY-MM-DD format (leave empty to keep current) + comment: Optional journal note + """ + payload: dict = {} + if subject: + payload["subject"] = subject + if description: + payload["description"] = description + if priority_id: + payload["priority_id"] = priority_id + if due_date: + payload["due_date"] = due_date + if comment: + payload["notes"] = comment + + if not payload: + return "Nothing to update — all fields are empty." + + _put(f"/issues/{issue_id}.json", {"issue": payload}) + return f"Issue #{issue_id} updated: {list(payload.keys())}." + + +@mcp.tool() +def update_issue_status(issue_id: int, status_id: int, comment: str = "") -> str: + """ + Change the status of a Redmine issue. + + Args: + issue_id: Redmine issue ID + status_id: Target status ID (use get_issue_statuses to find IDs) + comment: Optional comment to add with the status change + """ + body: dict = {"issue": {"status_id": status_id}} + if comment: + body["issue"]["notes"] = comment + _put(f"/issues/{issue_id}.json", body) + return f"Issue #{issue_id} status updated to status_id={status_id}." + + +@mcp.tool() +def update_issue_progress(issue_id: int, done_ratio: int, comment: str = "") -> str: + """ + Update the % done (progress) of a Redmine issue. + + Args: + issue_id: Redmine issue ID + done_ratio: Progress value 0–100 (percent done) + comment: Optional comment to add with the update + """ + if not 0 <= done_ratio <= 100: + return "Error: done_ratio must be between 0 and 100." + body: dict = {"issue": {"done_ratio": done_ratio}} + if comment: + body["issue"]["notes"] = comment + _put(f"/issues/{issue_id}.json", body) + return f"Issue #{issue_id} progress updated to {done_ratio}%." diff --git a/app/entities/members.py b/app/entities/members.py new file mode 100644 index 0000000..58652b0 --- /dev/null +++ b/app/entities/members.py @@ -0,0 +1,41 @@ +import json +from ..mcp import mcp +from ..client import _get, _put + + +@mcp.tool() +def get_project_members(project_id: str) -> str: + """ + Get members of a Redmine project (useful for finding user IDs for assignment). + + Args: + project_id: Project ID or identifier (slug) + """ + data = _get(f"/projects/{project_id}/memberships.json") + members = [ + { + "id": m.get("user", {}).get("id"), + "name": m.get("user", {}).get("name"), + "roles": [r["name"] for r in m.get("roles", [])], + } + for m in data.get("memberships", []) + if "user" in m + ] + return json.dumps(members, ensure_ascii=False, indent=2) + + +@mcp.tool() +def assign_issue(issue_id: int, assigned_to_id: int, comment: str = "") -> str: + """ + Assign a Redmine issue to a user. + + Args: + issue_id: Redmine issue ID + assigned_to_id: User ID to assign to (use get_project_members to find IDs) + comment: Optional comment to add with the assignment + """ + body: dict = {"issue": {"assigned_to_id": assigned_to_id}} + if comment: + body["issue"]["notes"] = comment + _put(f"/issues/{issue_id}.json", body) + return f"Issue #{issue_id} assigned to user_id={assigned_to_id}." diff --git a/app/entities/search.py b/app/entities/search.py new file mode 100644 index 0000000..3162cf9 --- /dev/null +++ b/app/entities/search.py @@ -0,0 +1,67 @@ +import json +from ..mcp import mcp +from ..client import _get + + +@mcp.tool() +def search( + query: str, + project_id: str = "", + scope: str = "all", + issues: bool = True, + wiki_pages: bool = True, + news: bool = False, + documents: bool = False, + changesets: bool = False, + messages: bool = False, + projects: bool = False, + titles_only: bool = False, + open_issues: bool = False, + attachments: bool = False, + limit: int = 25, + offset: int = 0, +) -> str: + """ + Search Redmine by keyword across multiple resource types. + + Args: + query: Search keyword(s) + project_id: Limit search to a specific project (ID or slug); empty = global + scope: "all" (all projects) or "my_projects" (only projects I'm member of) + issues: Include issues in results (default True) + wiki_pages: Include wiki pages in results (default True) + news: Include news in results + documents: Include documents in results + changesets: Include changesets/commits in results + messages: Include forum messages in results + projects: Include projects in results + titles_only: Search in titles/subjects only (skip descriptions/content) + open_issues: Return open issues only + attachments: Search inside attachment names/content + limit: Max results (1–100, default 25) + offset: Pagination offset + """ + params: dict = { + "q": query, + "scope": scope, + "all_words": 1, + "titles_only": int(titles_only), + "open_issues": int(open_issues), + "attachments": int(attachments), + "issues": int(issues), + "wiki_pages": int(wiki_pages), + "news": int(news), + "documents": int(documents), + "changesets": int(changesets), + "messages": int(messages), + "projects": int(projects), + "limit": min(limit, 100), + "offset": offset, + } + if project_id: + params["project_id"] = project_id + + data = _get("/search.json", params=params) + results = data.get("results", []) + total = data.get("total_count", len(results)) + return json.dumps({"total": total, "results": results}, ensure_ascii=False, indent=2) diff --git a/app/entities/statuses.py b/app/entities/statuses.py new file mode 100644 index 0000000..dcb95f1 --- /dev/null +++ b/app/entities/statuses.py @@ -0,0 +1,13 @@ +import json +from ..mcp import mcp +from ..client import _get + + +@mcp.tool() +def get_issue_statuses() -> str: + """ + Get all available issue statuses in Redmine. + Use this to find the correct status_id before calling update_issue_status. + """ + data = _get("/issue_statuses.json") + return json.dumps(data["issue_statuses"], ensure_ascii=False, indent=2) diff --git a/app/mcp.py b/app/mcp.py new file mode 100644 index 0000000..8c88ab6 --- /dev/null +++ b/app/mcp.py @@ -0,0 +1,3 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("redmine") diff --git a/main.py b/main.py index e315a96..f98ef9e 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,3 @@ -def main(): - print("Hello from redmine-mcp!") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +import runpy +runpy.run_path("server.py", run_name="__main__") diff --git a/server.py b/server.py index 569c644..a5bf3d5 100644 --- a/server.py +++ b/server.py @@ -1,278 +1,21 @@ #!/usr/bin/env python3 """ -Redmine MCP Server -Exposes Redmine REST API as MCP tools for Claude Code. +Redmine MCP Server — entry point. Environment variables: REDMINE_URL — base URL, e.g. https://redmine.example.com REDMINE_API_KEY — your personal API key (My account → API access key) + MCP_TRANSPORT — "stdio" (default) or "http" + MCP_HOST — bind host for HTTP transport (default: 0.0.0.0) + MCP_PORT — bind port for HTTP transport (default: 8000) """ import os -import json -import httpx -from mcp.server.fastmcp import FastMCP - -REDMINE_URL = os.environ.get("REDMINE_URL", "").rstrip("/") -REDMINE_API_KEY = os.environ.get("REDMINE_API_KEY", "") - -mcp = FastMCP("redmine") - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - -def _headers() -> dict: - return { - "X-Redmine-API-Key": REDMINE_API_KEY, - "Content-Type": "application/json", - } - - -def _get(path: str, params: dict | None = None) -> dict: - url = f"{REDMINE_URL}{path}" - with httpx.Client(timeout=15) as client: - resp = client.get(url, headers=_headers(), params=params or {}) - resp.raise_for_status() - return resp.json() - - -def _put(path: str, body: dict) -> dict: - url = f"{REDMINE_URL}{path}" - with httpx.Client(timeout=15) as client: - resp = client.put(url, headers=_headers(), content=json.dumps(body)) - resp.raise_for_status() - # Redmine returns 200 with body or 204 with no body - return resp.json() if resp.content else {} - - -# --------------------------------------------------------------------------- -# Tools -# --------------------------------------------------------------------------- - -@mcp.tool() -def get_issue(issue_id: int) -> str: - """ - Get a Redmine issue by ID. - Returns full issue details: title, description, status, priority, - assignee, progress, dates, custom fields. - """ - data = _get(f"/issues/{issue_id}.json") - issue = data["issue"] - return json.dumps(issue, ensure_ascii=False, indent=2) - - -@mcp.tool() -def get_issue_statuses() -> str: - """ - Get all available issue statuses in Redmine. - Use this to find the correct status_id before calling update_issue_status. - """ - data = _get("/issue_statuses.json") - return json.dumps(data["issue_statuses"], ensure_ascii=False, indent=2) - - -@mcp.tool() -def update_issue_status(issue_id: int, status_id: int, comment: str = "") -> str: - """ - Change the status of a Redmine issue. - - Args: - issue_id: Redmine issue ID - status_id: Target status ID (use get_issue_statuses to find IDs) - comment: Optional comment to add with the status change - """ - body: dict = {"issue": {"status_id": status_id}} - if comment: - body["issue"]["notes"] = comment - _put(f"/issues/{issue_id}.json", body) - return f"Issue #{issue_id} status updated to status_id={status_id}." - - -@mcp.tool() -def update_issue_progress(issue_id: int, done_ratio: int, comment: str = "") -> str: - """ - Update the % done (progress) of a Redmine issue. - - Args: - issue_id: Redmine issue ID - done_ratio: Progress value 0–100 (percent done) - comment: Optional comment to add with the update - """ - if not 0 <= done_ratio <= 100: - return "Error: done_ratio must be between 0 and 100." - body: dict = {"issue": {"done_ratio": done_ratio}} - if comment: - body["issue"]["notes"] = comment - _put(f"/issues/{issue_id}.json", body) - return f"Issue #{issue_id} progress updated to {done_ratio}%." - - -@mcp.tool() -def get_issue_comments(issue_id: int) -> str: - """ - Get all comments (journal notes) for a Redmine issue. - Returns list of comments with author, date, and text. - """ - data = _get(f"/issues/{issue_id}.json", params={"include": "journals"}) - journals = data["issue"].get("journals", []) - comments = [ - { - "id": j["id"], - "author": j["user"]["name"], - "created_on": j["created_on"], - "notes": j.get("notes", ""), - "details": j.get("details", []), # field changes - } - for j in journals - ] - return json.dumps(comments, ensure_ascii=False, indent=2) - - -@mcp.tool() -def add_issue_comment(issue_id: int, comment: str) -> str: - """ - Add a comment (journal note) to a Redmine issue. - - Args: - issue_id: Redmine issue ID - comment: Text of the comment to add - """ - _put(f"/issues/{issue_id}.json", {"issue": {"notes": comment}}) - return f"Comment added to issue #{issue_id}." - - -@mcp.tool() -def assign_issue(issue_id: int, assigned_to_id: int, comment: str = "") -> str: - """ - Assign a Redmine issue to a user. - - Args: - issue_id: Redmine issue ID - assigned_to_id: User ID to assign to (use get_project_members to find IDs) - comment: Optional comment to add with the assignment - """ - body: dict = {"issue": {"assigned_to_id": assigned_to_id}} - if comment: - body["issue"]["notes"] = comment - _put(f"/issues/{issue_id}.json", body) - return f"Issue #{issue_id} assigned to user_id={assigned_to_id}." - - -@mcp.tool() -def get_project_members(project_id: str) -> str: - """ - Get members of a Redmine project (useful for finding user IDs for assignment). - - Args: - project_id: Project ID or identifier (slug) - """ - data = _get(f"/projects/{project_id}/memberships.json") - members = [ - { - "id": m.get("user", {}).get("id"), - "name": m.get("user", {}).get("name"), - "roles": [r["name"] for r in m.get("roles", [])], - } - for m in data.get("memberships", []) - if "user" in m # skip group memberships - ] - return json.dumps(members, ensure_ascii=False, indent=2) - - -@mcp.tool() -def list_issues( - project_id: str = "", - status_id: str = "open", - assigned_to_id: str = "", - limit: int = 25, - offset: int = 0, -) -> str: - """ - List Redmine issues with optional filters. - - Args: - project_id: Filter by project ID or slug (empty = all projects) - status_id: "open", "closed", "*" (all), or a numeric status ID - assigned_to_id: Numeric user ID, or "me" for the API key owner - limit: Max results (1–100, default 25) - offset: Pagination offset - """ - params: dict = { - "status_id": status_id, - "limit": min(limit, 100), - "offset": offset, - } - if assigned_to_id: - params["assigned_to_id"] = assigned_to_id - - path = f"/projects/{project_id}/issues.json" if project_id else "/issues.json" - data = _get(path, params=params) - - issues = [ - { - "id": i["id"], - "subject": i["subject"], - "status": i["status"]["name"], - "priority": i["priority"]["name"], - "assigned_to": i.get("assigned_to", {}).get("name", "—"), - "done_ratio": i.get("done_ratio", 0), - "updated_on": i.get("updated_on", ""), - } - for i in data.get("issues", []) - ] - total = data.get("total_count", len(issues)) - return json.dumps({"total": total, "issues": issues}, ensure_ascii=False, indent=2) - - -@mcp.tool() -def update_issue( - issue_id: int, - subject: str = "", - description: str = "", - priority_id: int = 0, - due_date: str = "", - comment: str = "", -) -> str: - """ - Update general fields of a Redmine issue. - - Args: - issue_id: Redmine issue ID - subject: New title (leave empty to keep current) - description: New description (leave empty to keep current) - priority_id: Priority ID (leave 0 to keep current) - due_date: Due date in YYYY-MM-DD format (leave empty to keep current) - comment: Optional journal note - """ - payload: dict = {} - if subject: - payload["subject"] = subject - if description: - payload["description"] = description - if priority_id: - payload["priority_id"] = priority_id - if due_date: - payload["due_date"] = due_date - if comment: - payload["notes"] = comment - - if not payload: - return "Nothing to update — all fields are empty." - - _put(f"/issues/{issue_id}.json", {"issue": payload}) - return f"Issue #{issue_id} updated: {list(payload.keys())}." - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- +from app.config import validate +from app import mcp # imports entities as a side effect via __init__ if __name__ == "__main__": - if not REDMINE_URL or not REDMINE_API_KEY: - raise RuntimeError( - "REDMINE_URL and REDMINE_API_KEY environment variables must be set." - ) + validate() transport = os.environ.get("MCP_TRANSPORT", "stdio") if transport == "http": mcp.settings.host = os.environ.get("MCP_HOST", "0.0.0.0")