mirror of
https://github.com/shadoll/redmine-mcp.git
synced 2026-08-28 11:33:28 +00:00
refactor, add modularity
This commit is contained in:
@@ -7,15 +7,16 @@ Exposes Redmine REST API as tools for Claude Code via MCP (Model Context Protoco
|
|||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `get_issue` | Get issue details by ID |
|
| `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_status` | Change issue status |
|
||||||
| `update_issue_progress` | Set % done (0–100) |
|
| `update_issue_progress` | Set % done (0–100) |
|
||||||
| `get_issue_comments` | Get all comments and field changes |
|
| `get_issue_comments` | Get all comments and field changes |
|
||||||
| `add_issue_comment` | Post a new comment |
|
| `add_issue_comment` | Post a new comment |
|
||||||
| `assign_issue` | Assign issue to a user |
|
| `assign_issue` | Assign issue to a user |
|
||||||
| `get_project_members` | List project members with user IDs |
|
| `get_project_members` | List project members with user IDs |
|
||||||
| `list_issues` | Query issues with filters (project, status, assignee) |
|
| `get_issue_statuses` | List all available statuses (use to find status IDs) |
|
||||||
| `update_issue` | Update title, description, priority, due date |
|
| `search` | Search by keyword across issues, wiki, news, documents, and more |
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ claude mcp list
|
|||||||
Once registered, just ask Claude naturally in any project:
|
Once registered, just ask Claude naturally in any project:
|
||||||
|
|
||||||
> "Get issue #1234"
|
> "Get issue #1234"
|
||||||
|
> "Search for issues related to login bug"
|
||||||
> "Change status of issue #1234 to In Progress"
|
> "Change status of issue #1234 to In Progress"
|
||||||
> "Add a comment to issue #1234: deployment done"
|
> "Add a comment to issue #1234: deployment done"
|
||||||
> "Who is assigned to issue #1234?"
|
> "Who is assigned to issue #1234?"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .mcp import mcp
|
||||||
|
from . import entities
|
||||||
|
|
||||||
|
__all__ = ["mcp", "entities"]
|
||||||
@@ -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 {}
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from . import issues, comments, members, statuses, search
|
||||||
|
|
||||||
|
__all__ = ["issues", "comments", "members", "statuses", "search"]
|
||||||
@@ -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}."
|
||||||
@@ -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}%."
|
||||||
@@ -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}."
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
mcp = FastMCP("redmine")
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
def main():
|
#!/usr/bin/env python3
|
||||||
print("Hello from redmine-mcp!")
|
import runpy
|
||||||
|
runpy.run_path("server.py", run_name="__main__")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|||||||
@@ -1,278 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Redmine MCP Server
|
Redmine MCP Server — entry point.
|
||||||
Exposes Redmine REST API as MCP tools for Claude Code.
|
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
REDMINE_URL — base URL, e.g. https://redmine.example.com
|
REDMINE_URL — base URL, e.g. https://redmine.example.com
|
||||||
REDMINE_API_KEY — your personal API key (My account → API access key)
|
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 os
|
||||||
import json
|
from app.config import validate
|
||||||
import httpx
|
from app import mcp # imports entities as a side effect via __init__
|
||||||
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
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if not REDMINE_URL or not REDMINE_API_KEY:
|
validate()
|
||||||
raise RuntimeError(
|
|
||||||
"REDMINE_URL and REDMINE_API_KEY environment variables must be set."
|
|
||||||
)
|
|
||||||
transport = os.environ.get("MCP_TRANSPORT", "stdio")
|
transport = os.environ.get("MCP_TRANSPORT", "stdio")
|
||||||
if transport == "http":
|
if transport == "http":
|
||||||
mcp.settings.host = os.environ.get("MCP_HOST", "0.0.0.0")
|
mcp.settings.host = os.environ.get("MCP_HOST", "0.0.0.0")
|
||||||
|
|||||||
Reference in New Issue
Block a user