Skip to main content
Glama

Redmine MCP Server

by snowild
server.py42.4 kB
""" Redmine MCP 服務器主程式 提供與 Redmine 系統整合的 MCP 工具 """ import os from typing import Any from datetime import datetime # 確保配置在 FastMCP 初始化之前載入 # 這會處理所有環境變數設定,包含 FASTMCP_LOG_LEVEL from .config import get_config config = get_config() from mcp.server.fastmcp import FastMCP from .redmine_client import get_client, RedmineAPIError # 建立 FastMCP 服務器實例 mcp = FastMCP("Redmine MCP") @mcp.tool() def server_info() -> str: """取得服務器資訊和狀態""" config = get_config() return f"""Redmine MCP 服務器已啟動 - Redmine 網域: {config.redmine_domain} - 除錯模式: {config.debug_mode} - API 逾時: {config.redmine_timeout}秒""" @mcp.tool() def health_check() -> str: """健康檢查工具,確認服務器正常運作""" try: config = get_config() client = get_client() # 測試連線 if client.test_connection(): return f"✓ 服務器正常運作,已連接到 {config.redmine_domain}" else: return f"✗ 無法連接到 Redmine 服務器: {config.redmine_domain}" except Exception as e: return f"✗ 服務器異常: {str(e)}" @mcp.tool() def get_issue(issue_id: int, include_details: bool = True) -> str: """ 取得指定的 Redmine 議題詳細資訊 Args: issue_id: 議題 ID include_details: 是否包含詳細資訊(描述、備註、附件等) Returns: 議題的詳細資訊,以易讀格式呈現 """ try: client = get_client() include_params = [] if include_details: include_params = ['attachments', 'changesets', 'children', 'journals', 'relations', 'watchers'] # 使用新的 get_issue_raw 方法取得完整資料 issue_data = client.get_issue_raw(issue_id, include=include_params) # 格式化基本議題資訊 # 處理父議題資訊 parent_info = "無父議題" if 'parent' in issue_data and issue_data['parent']: parent_info = f"#{issue_data['parent']['id']} - {issue_data['parent'].get('subject', 'N/A')}" result = f"""議題 #{issue_data['id']}: {issue_data['subject']} 基本資訊: - 專案: {issue_data['project'].get('name', 'N/A')} (ID: {issue_data['project'].get('id', 'N/A')}) - 追蹤器: {issue_data['tracker'].get('name', 'N/A')} - 狀態: {issue_data['status'].get('name', 'N/A')} - 優先級: {issue_data['priority'].get('name', 'N/A')} - 建立者: {issue_data['author'].get('name', 'N/A')} - 指派給: {issue_data.get('assigned_to', {}).get('name', '未指派') if issue_data.get('assigned_to') else '未指派'} - 父議題: {parent_info} - 完成度: {issue_data.get('done_ratio', 0)}% - 開始日期: {issue_data.get('start_date', '未設定')} - 完成日期: {issue_data.get('due_date', '未設定')} - 預估工時: {issue_data.get('estimated_hours', '未設定')} 小時 - 建立時間: {issue_data.get('created_on', 'N/A')} - 更新時間: {issue_data.get('updated_on', 'N/A')} 描述: {issue_data.get('description', '無描述')}""" # 加入附件資訊 if include_details and 'attachments' in issue_data and issue_data['attachments']: result += f"\n\n附件 ({len(issue_data['attachments'])} 個):" for attachment in issue_data['attachments']: file_size = attachment.get('filesize', 0) file_size_mb = file_size / (1024 * 1024) if file_size > 0 else 0 size_text = f"{file_size_mb:.2f} MB" if file_size_mb >= 1 else f"{file_size} bytes" result += f""" - 檔名: {attachment.get('filename', 'N/A')} 大小: {size_text} 類型: {attachment.get('content_type', 'N/A')} 上傳者: {attachment.get('author', {}).get('name', 'N/A')} 上傳時間: {attachment.get('created_on', 'N/A')} 下載連結: {client.config.redmine_domain}/attachments/download/{attachment.get('id', '')}/{attachment.get('filename', '')}""" # 加入備註/歷史記錄 if include_details and 'journals' in issue_data and issue_data['journals']: # 過濾出有備註內容的記錄 notes_journals = [j for j in issue_data['journals'] if j.get('notes', '').strip()] if notes_journals: result += f"\n\n備註/歷史記錄 ({len(notes_journals)} 筆):" for i, journal in enumerate(notes_journals, 1): author_name = journal.get('user', {}).get('name', 'N/A') created_on = journal.get('created_on', 'N/A') notes = journal.get('notes', '').strip() result += f""" #{i} - {author_name} ({created_on}): {notes}""" return result except RedmineAPIError as e: return f"取得議題失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def update_issue_status(issue_id: int, status_id: int = None, status_name: str = None, notes: str = "") -> str: """ 更新議題狀態 Args: issue_id: 議題 ID status_id: 新的狀態 ID(與 status_name 二選一) status_name: 新的狀態名稱(與 status_id 二選一) notes: 更新備註(可選) Returns: 更新結果訊息 """ try: client = get_client() # 處理狀態參數 final_status_id = status_id if status_name: final_status_id = client.find_status_id_by_name(status_name) if not final_status_id: return f"找不到狀態名稱:「{status_name}」\n\n可用狀態:\n" + "\n".join([f"- {name}" for name in client.get_available_statuses().keys()]) if not final_status_id: return "錯誤:必須提供 status_id 或 status_name 其中一個參數" # 準備更新資料 update_data = {'status_id': final_status_id} if notes.strip(): update_data['notes'] = notes.strip() # 執行更新 client.update_issue(issue_id, **update_data) # 取得更新後的議題資訊確認 updated_issue = client.get_issue(issue_id) result = f"""議題狀態更新成功! 議題: #{issue_id} - {updated_issue.subject} 新狀態: {updated_issue.status.get('name', 'N/A')}""" if notes.strip(): result += f"\n備註: {notes}" return result except RedmineAPIError as e: return f"更新議題狀態失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def list_project_issues(project_id: int, status_filter: str = "open", limit: int = 20) -> str: """ 列出專案的議題 Args: project_id: 專案 ID status_filter: 狀態篩選 ("open", "closed", "all") limit: 最大回傳數量 (預設 20,最大 100) Returns: 專案議題列表,以表格格式呈現 """ try: client = get_client() # 限制 limit 範圍 limit = min(max(limit, 1), 100) # 根據狀態篩選設定參數 params = { 'project_id': project_id, 'limit': limit, 'sort': 'updated_on:desc' } # 處理狀態篩選 if status_filter == "open": params['status_id'] = 'o' # Redmine API 使用 'o' 表示開放狀態 elif status_filter == "closed": params['status_id'] = 'c' # Redmine API 使用 'c' 表示關閉狀態 # "all" 則不設定 status_id # 取得議題列表 issues = client.list_issues(**params) if not issues: return f"專案 {project_id} 中沒有找到符合條件的議題" # 取得專案資訊 try: project = client.get_project(project_id) project_name = project.name except: project_name = f"專案 {project_id}" # 格式化議題列表 result = f"""專案: {project_name} 狀態篩選: {status_filter} 找到 {len(issues)} 個議題: {"ID":<8} {"標題":<40} {"狀態":<12} {"指派給":<15} {"更新時間":<10} {"-"*8} {"-"*40} {"-"*12} {"-"*15} {"-"*10}""" for issue in issues: title = issue.subject[:37] + "..." if len(issue.subject) > 40 else issue.subject status = issue.status.get('name', 'N/A')[:10] assignee = issue.assigned_to.get('name', '未指派')[:13] if issue.assigned_to else '未指派' updated = issue.updated_on[:10] if issue.updated_on else 'N/A' result += f"\n{issue.id:<8} {title:<40} {status:<12} {assignee:<15} {updated:<10}" return result except RedmineAPIError as e: return f"列出專案議題失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def get_issue_statuses() -> str: """ 取得所有可用的議題狀態列表 Returns: 格式化的狀態列表 """ try: client = get_client() statuses = client.get_issue_statuses() if not statuses: return "沒有找到議題狀態" result = "可用的議題狀態:\n\n" result += f"{'ID':<5} {'名稱':<15} {'已關閉':<8}\n" result += f"{'-'*5} {'-'*15} {'-'*8}\n" for status in statuses: is_closed = "是" if status.get('is_closed', False) else "否" result += f"{status['id']:<5} {status['name']:<15} {is_closed:<8}\n" return result except RedmineAPIError as e: return f"取得議題狀態失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def get_trackers() -> str: """ 取得所有可用的追蹤器列表 Returns: 格式化的追蹤器列表 """ try: client = get_client() trackers = client.get_trackers() if not trackers: return "沒有找到追蹤器" result = "可用的追蹤器:\n\n" result += f"{'ID':<5} {'名稱':<20} {'預設狀態':<12}\n" result += f"{'-'*5} {'-'*20} {'-'*12}\n" for tracker in trackers: default_status = tracker.get('default_status', {}).get('name', 'N/A') result += f"{tracker['id']:<5} {tracker['name']:<20} {default_status:<12}\n" return result except RedmineAPIError as e: return f"取得追蹤器列表失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def get_priorities() -> str: """ 取得所有可用的議題優先級列表 Returns: 格式化的優先級列表 """ try: client = get_client() priorities = client.get_priorities() if not priorities: return "沒有找到議題優先級" result = "可用的議題優先級:\n\n" result += f"{'ID':<5} {'名稱':<15} {'預設':<8}\n" result += f"{'-'*5} {'-'*15} {'-'*8}\n" for priority in priorities: is_default = "是" if priority.get('is_default', False) else "否" result += f"{priority['id']:<5} {priority['name']:<15} {is_default:<8}\n" return result except RedmineAPIError as e: return f"取得議題優先級失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def get_time_entry_activities() -> str: """ 取得所有可用的時間追蹤活動列表 Returns: 格式化的時間追蹤活動列表 """ try: client = get_client() activities = client.get_time_entry_activities() if not activities: return "沒有找到時間追蹤活動" result = "可用的時間追蹤活動:\n\n" result += f"{'ID':<5} {'名稱':<20} {'預設':<8}\n" result += f"{'-'*5} {'-'*20} {'-'*8}\n" for activity in activities: is_default = "是" if activity.get('is_default', False) else "否" result += f"{activity['id']:<5} {activity['name']:<20} {is_default:<8}\n" return result except RedmineAPIError as e: return f"取得時間追蹤活動失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def get_document_categories() -> str: """ 取得所有可用的文件分類列表 Returns: 格式化的文件分類列表 """ try: client = get_client() categories = client.get_document_categories() if not categories: return "沒有找到文件分類" result = "可用的文件分類:\n\n" result += f"{'ID':<5} {'名稱':<25} {'預設':<8}\n" result += f"{'-'*5} {'-'*25} {'-'*8}\n" for category in categories: is_default = "是" if category.get('is_default', False) else "否" result += f"{category['id']:<5} {category['name']:<25} {is_default:<8}\n" return result except RedmineAPIError as e: return f"取得文件分類失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def get_projects() -> str: """ 取得可存取的專案列表 Returns: 格式化的專案列表 """ try: client = get_client() projects = client.list_projects(limit=50) if not projects: return "沒有找到可存取的專案" result = f"找到 {len(projects)} 個專案:\n\n" result += f"{'ID':<5} {'識別碼':<20} {'名稱':<30} {'狀態':<8}\n" result += f"{'-'*5} {'-'*20} {'-'*30} {'-'*8}\n" for project in projects: status_text = "正常" if project.status == 1 else "封存" name = project.name[:27] + "..." if len(project.name) > 30 else project.name result += f"{project.id:<5} {project.identifier:<20} {name:<30} {status_text:<8}\n" return result except RedmineAPIError as e: return f"取得專案列表失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def search_issues(query: str, project_id: int = None, limit: int = 10) -> str: """ 搜尋議題 (在標題或描述中搜尋關鍵字) Args: query: 搜尋關鍵字 project_id: 限制在特定專案中搜尋 (可選) limit: 最大回傳數量 (預設 10,最大 50) Returns: 符合搜尋條件的議題列表 """ try: if not query.strip(): return "請提供搜尋關鍵字" client = get_client() limit = min(max(limit, 1), 50) # 設定搜尋參數 params = { 'limit': limit * 3, # 取得更多結果以便篩選 'sort': 'updated_on:desc' } if project_id: params['project_id'] = project_id # 取得議題列表 all_issues = client.list_issues(**params) # 在本地端進行關鍵字篩選 (因為 Redmine API 沒有內建搜尋) query_lower = query.lower() matching_issues = [] for issue in all_issues: if (query_lower in issue.subject.lower() or (issue.description and query_lower in issue.description.lower())): matching_issues.append(issue) if len(matching_issues) >= limit: break if not matching_issues: search_scope = f"專案 {project_id}" if project_id else "所有可存取的專案" return f"在 {search_scope} 中沒有找到包含 '{query}' 的議題" # 格式化結果 result = f"搜尋關鍵字: '{query}'\n" if project_id: result += f"搜尋範圍: 專案 {project_id}\n" result += f"找到 {len(matching_issues)} 個相關議題:\n\n" result += f"{'ID':<8} {'標題':<35} {'狀態':<12} {'專案':<15}\n" result += f"{'-'*8} {'-'*35} {'-'*12} {'-'*15}\n" for issue in matching_issues: title = issue.subject[:32] + "..." if len(issue.subject) > 35 else issue.subject status = issue.status.get('name', 'N/A')[:10] project_name = issue.project.get('name', 'N/A')[:13] result += f"{issue.id:<8} {title:<35} {status:<12} {project_name:<15}\n" return result except RedmineAPIError as e: return f"搜尋議題失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def update_issue_content(issue_id: int, subject: str = None, description: str = None, priority_id: int = None, priority_name: str = None, done_ratio: int = None, tracker_id: int = None, tracker_name: str = None, parent_issue_id: int = None, remove_parent: bool = False, start_date: str = None, due_date: str = None, estimated_hours: float = None) -> str: """ 更新議題內容(標題、描述、優先級、完成度、追蹤器、日期、工時等) Args: issue_id: 議題 ID subject: 新的議題標題(可選) description: 新的議題描述(可選) priority_id: 新的優先級 ID(與 priority_name 二選一) priority_name: 新的優先級名稱(與 priority_id 二選一) done_ratio: 新的完成百分比 0-100(可選) tracker_id: 新的追蹤器 ID(與 tracker_name 二選一) tracker_name: 新的追蹤器名稱(與 tracker_id 二選一) parent_issue_id: 新的父議題 ID(可選) remove_parent: 是否移除父議題關係(可選) start_date: 新的開始日期 YYYY-MM-DD 格式(可選) due_date: 新的完成日期 YYYY-MM-DD 格式(可選) estimated_hours: 新的預估工時(可選) Returns: 更新結果訊息 """ try: client = get_client() # 準備更新資料 update_data = {} changes = [] if subject is not None: update_data['subject'] = subject.strip() changes.append(f"標題: {subject}") if description is not None: update_data['description'] = description changes.append("描述已更新") # 處理優先級參數 if priority_name: priority_id = client.find_priority_id_by_name(priority_name) if not priority_id: return f"找不到優先級名稱:「{priority_name}」\n\n可用優先級:\n" + "\n".join([f"- {name}" for name in client.get_available_priorities().keys()]) if priority_id is not None: update_data['priority_id'] = priority_id changes.append(f"優先級 ID: {priority_id}") if done_ratio is not None: if not (0 <= done_ratio <= 100): return "錯誤: 完成百分比必須在 0-100 之間" update_data['done_ratio'] = done_ratio changes.append(f"完成度: {done_ratio}%") # 處理追蹤器參數 if tracker_name: tracker_id = client.find_tracker_id_by_name(tracker_name) if not tracker_id: return f"找不到追蹤器名稱:「{tracker_name}」\n\n可用追蹤器:\n" + "\n".join([f"- {name}" for name in client.get_available_trackers().keys()]) if tracker_id is not None: update_data['tracker_id'] = tracker_id changes.append(f"追蹤器 ID: {tracker_id}") if remove_parent: update_data['parent_issue_id'] = None changes.append("移除父議題關係") elif parent_issue_id is not None: update_data['parent_issue_id'] = parent_issue_id changes.append(f"父議題 ID: {parent_issue_id}") if start_date is not None: # 驗證日期格式 try: from datetime import datetime datetime.strptime(start_date, '%Y-%m-%d') update_data['start_date'] = start_date changes.append(f"開始日期: {start_date}") except ValueError: return "錯誤: 開始日期格式必須為 YYYY-MM-DD" if due_date is not None: # 驗證日期格式 try: from datetime import datetime datetime.strptime(due_date, '%Y-%m-%d') update_data['due_date'] = due_date changes.append(f"完成日期: {due_date}") except ValueError: return "錯誤: 完成日期格式必須為 YYYY-MM-DD" if estimated_hours is not None: if estimated_hours < 0: return "錯誤: 預估工時不能為負數" update_data['estimated_hours'] = estimated_hours changes.append(f"預估工時: {estimated_hours} 小時") if not update_data and not changes: return "錯誤: 請至少提供一個要更新的欄位" # 執行更新 client.update_issue(issue_id, **update_data) # 取得更新後的議題資訊 updated_issue = client.get_issue(issue_id) result = f"""議題內容更新成功! 議題: #{issue_id} - {updated_issue.subject} 已更新的欄位: {chr(10).join(f"- {change}" for change in changes)} 目前狀態: - 追蹤器: {updated_issue.tracker.get('name', 'N/A')} - 狀態: {updated_issue.status.get('name', 'N/A')} - 優先級: {updated_issue.priority.get('name', 'N/A')} - 完成度: {updated_issue.done_ratio}%""" return result except RedmineAPIError as e: return f"更新議題內容失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def add_issue_note(issue_id: int, notes: str, private: bool = False, spent_hours: float = None, activity_name: str = None, activity_id: int = None, spent_on: str = None) -> str: """ 為議題新增備註,可同時記錄時間 Args: issue_id: 議題 ID notes: 備註內容 private: 是否為私有備註(預設否) spent_hours: 耗用工時(小時) activity_name: 活動名稱(與 activity_id 二選一) activity_id: 活動 ID(與 activity_name 二選一) spent_on: 記錄日期 YYYY-MM-DD 格式(可選,預設今日) Returns: 新增結果訊息 """ try: if not notes.strip(): return "錯誤: 備註內容不能為空" client = get_client() time_entry_id = None # 處理時間記錄 if spent_hours is not None: if spent_hours <= 0: return "錯誤: 耗用工時必須大於 0" # 處理活動參數 final_activity_id = activity_id if activity_name: final_activity_id = client.find_time_entry_activity_id_by_name(activity_name) if not final_activity_id: available_activities = client.get_available_time_entry_activities() return f"找不到時間追蹤活動名稱:「{activity_name}」\n\n可用活動:\n" + "\n".join([f"- {name}" for name in available_activities.keys()]) if not final_activity_id: return "錯誤: 必須提供 activity_id 或 activity_name 參數" # 建立時間記錄 try: time_entry_id = client.create_time_entry( issue_id=issue_id, hours=spent_hours, activity_id=final_activity_id, comments=notes.strip(), spent_on=spent_on ) except Exception as e: return f"建立時間記錄失敗: {str(e)}" # 準備更新資料(新增備註) update_data = {'notes': notes.strip()} if private: update_data['private_notes'] = True # 執行更新 client.update_issue(issue_id, **update_data) # 取得議題資訊 issue = client.get_issue(issue_id) privacy_text = "私有" if private else "公開" result = f"""備註新增成功! 議題: #{issue_id} - {issue.subject} 備註類型: {privacy_text} 備註內容: {notes.strip()}""" # 如果有建立時間記錄,添加相關資訊 if time_entry_id: from datetime import date actual_date = spent_on if spent_on else date.today().strftime('%Y-%m-%d') activity_name_display = activity_name if activity_name else f"ID {final_activity_id}" result += f""" 時間記錄新增成功! - 時間記錄 ID: {time_entry_id} - 耗用工時: {spent_hours} 小時 - 活動: {activity_name_display} - 記錄日期: {actual_date}""" return result except RedmineAPIError as e: return f"新增議題備註失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def assign_issue(issue_id: int, user_id: int = None, user_name: str = None, user_login: str = None, notes: str = "") -> str: """ 指派議題給用戶 Args: issue_id: 議題 ID user_id: 指派給的用戶 ID(與 user_name/user_login 三選一) user_name: 指派給的用戶姓名(與 user_id/user_login 三選一) user_login: 指派給的用戶登入名(與 user_id/user_name 三選一) notes: 指派備註(可選) Returns: 指派結果訊息 """ try: client = get_client() # 處理用戶參數 final_user_id = user_id if user_name: final_user_id = client.find_user_id_by_name(user_name) if not final_user_id: users = client.get_available_users() return f"找不到用戶姓名:「{user_name}」\n\n可用用戶(姓名):\n" + "\n".join([f"- {name}" for name in users['by_name'].keys()]) elif user_login: final_user_id = client.find_user_id_by_login(user_login) if not final_user_id: users = client.get_available_users() return f"找不到用戶登入名:「{user_login}」\n\n可用用戶(登入名):\n" + "\n".join([f"- {login}" for login in users['by_login'].keys()]) # 準備更新資料 update_data = {} if final_user_id is not None: update_data['assigned_to_id'] = final_user_id action_text = f"指派給用戶 ID {final_user_id}" else: update_data['assigned_to_id'] = None action_text = "取消指派" if notes.strip(): update_data['notes'] = notes.strip() # 執行更新 client.update_issue(issue_id, **update_data) # 取得更新後的議題資訊 updated_issue = client.get_issue(issue_id) assignee_name = "未指派" if updated_issue.assigned_to: assignee_name = updated_issue.assigned_to.get('name', f"用戶 ID {user_id}") result = f"""議題指派更新成功! 議題: #{issue_id} - {updated_issue.subject} 動作: {action_text} 目前指派給: {assignee_name}""" if notes.strip(): result += f"\n備註: {notes}" return result except RedmineAPIError as e: return f"指派議題失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def create_new_issue(project_id: int, subject: str, description: str = "", tracker_id: int = None, tracker_name: str = None, priority_id: int = None, priority_name: str = None, assigned_to_id: int = None, assigned_to_name: str = None, assigned_to_login: str = None) -> str: """ 建立新的 Redmine 議題 Args: project_id: 專案 ID subject: 議題標題 description: 議題描述(可選) tracker_id: 追蹤器 ID(與 tracker_name 二選一) tracker_name: 追蹤器名稱(與 tracker_id 二選一) priority_id: 優先級 ID(與 priority_name 二選一) priority_name: 優先級名稱(與 priority_id 二選一) assigned_to_id: 指派給的用戶 ID(與 assigned_to_name/assigned_to_login 三選一) assigned_to_name: 指派給的用戶姓名(與 assigned_to_id/assigned_to_login 三選一) assigned_to_login: 指派給的用戶登入名(與 assigned_to_id/assigned_to_name 三選一) Returns: 建立結果訊息 """ try: if not subject.strip(): return "錯誤: 議題標題不能為空" client = get_client() # 處理追蹤器參數 final_tracker_id = tracker_id if tracker_name: final_tracker_id = client.find_tracker_id_by_name(tracker_name) if not final_tracker_id: return f"找不到追蹤器名稱:「{tracker_name}」\n\n可用追蹤器:\n" + "\n".join([f"- {name}" for name in client.get_available_trackers().keys()]) # 處理優先級參數 final_priority_id = priority_id if priority_name: final_priority_id = client.find_priority_id_by_name(priority_name) if not final_priority_id: return f"找不到優先級名稱:「{priority_name}」\n\n可用優先級:\n" + "\n".join([f"- {name}" for name in client.get_available_priorities().keys()]) # 處理指派用戶參數 final_assigned_to_id = assigned_to_id if assigned_to_name: final_assigned_to_id = client.find_user_id_by_name(assigned_to_name) if not final_assigned_to_id: users = client.get_available_users() return f"找不到用戶姓名:「{assigned_to_name}」\n\n可用用戶(姓名):\n" + "\n".join([f"- {name}" for name in users['by_name'].keys()]) elif assigned_to_login: final_assigned_to_id = client.find_user_id_by_login(assigned_to_login) if not final_assigned_to_id: users = client.get_available_users() return f"找不到用戶登入名:「{assigned_to_login}」\n\n可用用戶(登入名):\n" + "\n".join([f"- {login}" for login in users['by_login'].keys()]) # 建立議題 new_issue_id = client.create_issue( project_id=project_id, subject=subject.strip(), description=description, tracker_id=final_tracker_id, priority_id=final_priority_id, assigned_to_id=final_assigned_to_id ) # 取得建立的議題資訊 new_issue = client.get_issue(new_issue_id) result = f"""新議題建立成功! 議題 ID: #{new_issue_id} 標題: {new_issue.subject} 專案: {new_issue.project.get('name', 'N/A')} 追蹤器: {new_issue.tracker.get('name', 'N/A')} 狀態: {new_issue.status.get('name', 'N/A')} 優先級: {new_issue.priority.get('name', 'N/A')} 指派給: {new_issue.assigned_to.get('name', '未指派') if new_issue.assigned_to else '未指派'}""" if description: result += f"\n\n描述:\n{description}" return result except RedmineAPIError as e: return f"建立議題失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def get_my_issues(status_filter: str = "open", limit: int = 20) -> str: """ 取得指派給我的議題列表 Args: status_filter: 狀態篩選 ("open", "closed", "all") limit: 最大回傳數量 (預設 20,最大 100) Returns: 我的議題列表 """ try: client = get_client() # 先取得當前用戶資訊 current_user = client.get_current_user() user_id = current_user['id'] user_name = current_user.get('firstname', '') + ' ' + current_user.get('lastname', '') # 限制 limit 範圍 limit = min(max(limit, 1), 100) # 設定查詢參數 params = { 'assigned_to_id': user_id, 'limit': limit, 'sort': 'updated_on:desc' } # 處理狀態篩選 if status_filter == "open": params['status_id'] = 'o' # Redmine API 使用 'o' 表示開放狀態 elif status_filter == "closed": params['status_id'] = 'c' # Redmine API 使用 'c' 表示關閉狀態 # 取得議題列表 issues = client.list_issues(**params) if not issues: return f"沒有找到指派給 {user_name.strip()} 的{status_filter}議題" # 格式化結果 result = f"""指派給 {user_name.strip()} 的議題: 狀態篩選: {status_filter} 找到 {len(issues)} 個議題: {"ID":<8} {"標題":<35} {"專案":<15} {"狀態":<12} {"更新時間":<10} {"-"*8} {"-"*35} {"-"*15} {"-"*12} {"-"*10}""" for issue in issues: title = issue.subject[:32] + "..." if len(issue.subject) > 35 else issue.subject project_name = issue.project.get('name', 'N/A')[:13] status = issue.status.get('name', 'N/A')[:10] updated = issue.updated_on[:10] if issue.updated_on else 'N/A' result += f"\n{issue.id:<8} {title:<35} {project_name:<15} {status:<12} {updated:<10}" return result except RedmineAPIError as e: return f"取得我的議題失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def close_issue(issue_id: int, notes: str = "", done_ratio: int = 100) -> str: """ 關閉議題(設定為已完成狀態) Args: issue_id: 議題 ID notes: 關閉備註(可選) done_ratio: 完成百分比(預設 100%) Returns: 關閉結果訊息 """ try: client = get_client() # 取得可用狀態列表,尋找關閉狀態 statuses = client.get_issue_statuses() closed_status_id = None for status in statuses: if status.get('is_closed', False): closed_status_id = status['id'] break if closed_status_id is None: return "錯誤: 找不到可用的關閉狀態" # 準備更新資料 update_data = { 'status_id': closed_status_id, 'done_ratio': min(max(done_ratio, 0), 100) } if notes.strip(): update_data['notes'] = notes.strip() # 執行更新 client.update_issue(issue_id, **update_data) # 取得更新後的議題資訊 updated_issue = client.get_issue(issue_id) result = f"""議題關閉成功! 議題: #{issue_id} - {updated_issue.subject} 狀態: {updated_issue.status.get('name', 'N/A')} 完成度: {updated_issue.done_ratio}%""" if notes.strip(): result += f"\n關閉備註: {notes}" return result except RedmineAPIError as e: return f"關閉議題失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def search_users(query: str, limit: int = 10) -> str: """ 搜尋用戶(依姓名或登入名) Args: query: 搜尋關鍵字(姓名或登入名) limit: 最大回傳數量 (預設 10,最大 50) Returns: 符合搜尋條件的用戶列表 """ try: if not query.strip(): return "請提供搜尋關鍵字" client = get_client() limit = min(max(limit, 1), 50) users = client.search_users(query, limit) if not users: return f"沒有找到匹配「{query}」的用戶" result = f"搜尋關鍵字: '{query}'\n找到 {len(users)} 個相關用戶:\n\n" result += f"{'ID':<5} {'登入名':<15} {'姓名':<20} {'狀態':<8}\n" result += f"{'-'*5} {'-'*15} {'-'*20} {'-'*8}\n" for user in users: full_name = f"{user.firstname} {user.lastname}".strip() if not full_name: full_name = user.login status_text = "啟用" if user.status == 1 else "停用" result += f"{user.id:<5} {user.login:<15} {full_name:<20} {status_text:<8}\n" return result except RedmineAPIError as e: return f"搜尋用戶失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def list_users(limit: int = 20, status_filter: str = "active") -> str: """ 列出所有用戶 Args: limit: 最大回傳數量 (預設 20,最大 100) status_filter: 狀態篩選 ("active", "locked", "all") Returns: 用戶列表,以表格格式呈現 """ try: client = get_client() limit = min(max(limit, 1), 100) # 轉換狀態篩選 status = None if status_filter == "active": status = 1 elif status_filter == "locked": status = 3 users = client.list_users(limit=limit, status=status) if not users: return "沒有找到用戶" result = f"找到 {len(users)} 個用戶:\n\n" result += f"{'ID':<5} {'登入名':<15} {'姓名':<20} {'Email':<25} {'狀態':<8}\n" result += f"{'-'*5} {'-'*15} {'-'*20} {'-'*25} {'-'*8}\n" for user in users: full_name = f"{user.firstname} {user.lastname}".strip() if not full_name: full_name = user.login status_text = "啟用" if user.status == 1 else "停用" email = user.mail[:22] + "..." if len(user.mail) > 25 else user.mail result += f"{user.id:<5} {user.login:<15} {full_name:<20} {email:<25} {status_text:<8}\n" return result except RedmineAPIError as e: return f"取得用戶列表失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def get_user(user_id: int) -> str: """ 取得特定用戶的詳細資訊 Args: user_id: 用戶 ID Returns: 用戶的詳細資訊,以易讀格式呈現 """ try: client = get_client() user_data = client.get_user(user_id) # 格式化用戶資訊 result = f"用戶 #{user_id}: {user_data.get('firstname', '')} {user_data.get('lastname', '')}\n\n" result += "基本資訊:\n" result += f"- 登入名: {user_data.get('login', 'N/A')}\n" result += f"- Email: {user_data.get('mail', 'N/A')}\n" result += f"- 狀態: {'啟用' if user_data.get('status', 1) == 1 else '停用'}\n" result += f"- 建立時間: {user_data.get('created_on', 'N/A')}\n" if user_data.get('last_login_on'): result += f"- 最後登入: {user_data.get('last_login_on')}\n" # 群組資訊 if user_data.get('groups'): result += "\n群組:\n" for group in user_data['groups']: result += f"- {group.get('name', 'N/A')}\n" # 自訂欄位 if user_data.get('custom_fields'): result += "\n自訂欄位:\n" for field in user_data['custom_fields']: if field.get('value'): result += f"- {field.get('name', 'N/A')}: {field.get('value', 'N/A')}\n" return result except RedmineAPIError as e: return f"取得用戶資訊失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" @mcp.tool() def refresh_cache() -> str: """ 手動刷新列舉值和用戶快取 Returns: 刷新結果訊息 """ try: client = get_client() client.refresh_cache() # 取得快取資訊 cache = client._load_enum_cache() domain = cache.get('domain', 'N/A') cache_time = cache.get('cache_time', 0) if cache_time > 0: cache_datetime = datetime.fromtimestamp(cache_time).strftime('%Y-%m-%d %H:%M:%S') else: cache_datetime = 'N/A' result = f"""快取刷新成功! Domain: {domain} 快取時間: {cache_datetime} 快取內容統計: - 優先權: {len(cache.get('priorities', {}))} 個 - 狀態: {len(cache.get('statuses', {}))} 個 - 追蹤器: {len(cache.get('trackers', {}))} 個 - 用戶(姓名): {len(cache.get('users_by_name', {}))} 個 - 用戶(登入名): {len(cache.get('users_by_login', {}))} 個 快取位置: {client._cache_file}""" return result except RedmineAPIError as e: return f"刷新快取失敗: {str(e)}" except Exception as e: return f"系統錯誤: {str(e)}" def main(): """MCP 服務器主入口點""" # 透過 stdio 運行服務器 mcp.run('stdio') if __name__ == "__main__": main()

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/snowild/redmine-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server