Skip to main content
Glama

Redmine MCP Server

by snowild
validators.py13.9 kB
""" 資料驗證和錯誤回饋機制 負責驗證 API 請求參數和提供友好的錯誤訊息 """ import re from typing import Any, Dict, List, Optional, Union from datetime import datetime from dataclasses import dataclass @dataclass class ValidationResult: """驗證結果""" is_valid: bool errors: List[str] warnings: List[str] = None def __post_init__(self): if self.warnings is None: self.warnings = [] class RedmineValidationError(Exception): """Redmine 資料驗證錯誤""" def __init__(self, message: str, field: str = None, errors: List[str] = None): super().__init__(message) self.field = field self.errors = errors or [message] class RedmineValidator: """Redmine 資料驗證器""" # 驗證規則常數 MAX_SUBJECT_LENGTH = 255 MAX_DESCRIPTION_LENGTH = 65535 MAX_PROJECT_NAME_LENGTH = 255 MAX_PROJECT_IDENTIFIER_LENGTH = 100 MIN_PROJECT_IDENTIFIER_LENGTH = 1 # 專案識別碼格式:只能包含小寫字母、數字、破折號和底線 PROJECT_IDENTIFIER_PATTERN = re.compile(r'^[a-z0-9\-_]+$') # 日期格式:YYYY-MM-DD 或 YYYY-MM-DDTHH:MM:SSZ DATE_PATTERNS = [ re.compile(r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$'), # YYYY-MM-DD (with validation) re.compile(r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T\d{2}:\d{2}:\d{2}Z?$'), # ISO format re.compile(r'^>=\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$'), # >=YYYY-MM-DD re.compile(r'^<=\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$'), # <=YYYY-MM-DD re.compile(r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\|\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$') # date range ] @classmethod def validate_issue_data(cls, data: Dict[str, Any], is_update: bool = False) -> ValidationResult: """驗證議題資料""" errors = [] warnings = [] # 必要欄位檢查(建立時) if not is_update: if 'project_id' not in data: errors.append("專案 ID (project_id) 為必填欄位") if 'subject' not in data: errors.append("議題標題 (subject) 為必填欄位") elif not isinstance(data.get('subject'), str) or not data.get('subject', '').strip(): errors.append("議題標題 (subject) 為必填欄位") # 議題標題驗證 if 'subject' in data: subject = data['subject'] if not isinstance(subject, str): errors.append("議題標題必須為文字格式") elif len(subject.strip()) == 0: errors.append("議題標題不能為空") elif len(subject) > cls.MAX_SUBJECT_LENGTH: errors.append(f"議題標題長度不能超過 {cls.MAX_SUBJECT_LENGTH} 字元") # 議題描述驗證 if 'description' in data: description = data['description'] if description is not None and not isinstance(description, str): errors.append("議題描述必須為文字格式") elif description and len(description) > cls.MAX_DESCRIPTION_LENGTH: errors.append(f"議題描述長度不能超過 {cls.MAX_DESCRIPTION_LENGTH} 字元") # ID 欄位驗證 id_fields = ['project_id', 'tracker_id', 'status_id', 'priority_id', 'assigned_to_id', 'parent_issue_id'] for field in id_fields: if field in data: value = data[field] if value is not None and (not isinstance(value, int) or value <= 0): errors.append(f"{field} 必須為正整數") # 完成百分比驗證 if 'done_ratio' in data: done_ratio = data['done_ratio'] if not isinstance(done_ratio, int) or done_ratio < 0 or done_ratio > 100: errors.append("完成百分比 (done_ratio) 必須為 0-100 之間的整數") # 自訂欄位驗證 if 'custom_fields' in data: custom_fields = data['custom_fields'] if custom_fields is not None: # 只有在不是 None 時才驗證 if not isinstance(custom_fields, list): errors.append("自訂欄位 (custom_fields) 必須為陣列格式") else: for i, field in enumerate(custom_fields): if not isinstance(field, dict): errors.append(f"自訂欄位 [{i}] 必須為物件格式") elif 'id' not in field: errors.append(f"自訂欄位 [{i}] 缺少必要的 id 欄位") return ValidationResult(is_valid=len(errors) == 0, errors=errors, warnings=warnings) @classmethod def validate_project_data(cls, data: Dict[str, Any], is_update: bool = False) -> ValidationResult: """驗證專案資料""" errors = [] warnings = [] # 必要欄位檢查(建立時) if not is_update: if 'name' not in data or not data.get('name', '').strip(): errors.append("專案名稱 (name) 為必填欄位") if 'identifier' not in data or not data.get('identifier', '').strip(): errors.append("專案識別碼 (identifier) 為必填欄位") # 專案名稱驗證 if 'name' in data: name = data['name'] if not isinstance(name, str): errors.append("專案名稱必須為文字格式") elif len(name.strip()) == 0: errors.append("專案名稱不能為空") elif len(name) > cls.MAX_PROJECT_NAME_LENGTH: errors.append(f"專案名稱長度不能超過 {cls.MAX_PROJECT_NAME_LENGTH} 字元") # 專案識別碼驗證 if 'identifier' in data: identifier = data['identifier'] if not isinstance(identifier, str): errors.append("專案識別碼必須為文字格式") elif len(identifier.strip()) == 0: errors.append("專案識別碼不能為空") elif len(identifier) < cls.MIN_PROJECT_IDENTIFIER_LENGTH: errors.append(f"專案識別碼長度不能少於 {cls.MIN_PROJECT_IDENTIFIER_LENGTH} 字元") elif len(identifier) > cls.MAX_PROJECT_IDENTIFIER_LENGTH: errors.append(f"專案識別碼長度不能超過 {cls.MAX_PROJECT_IDENTIFIER_LENGTH} 字元") elif not cls.PROJECT_IDENTIFIER_PATTERN.match(identifier): errors.append("專案識別碼只能包含小寫字母、數字、破折號和底線") elif identifier.lower() != identifier: warnings.append("建議專案識別碼使用小寫字母") # 專案描述驗證 if 'description' in data: description = data['description'] if description is not None and not isinstance(description, str): errors.append("專案描述必須為文字格式") # 布林欄位驗證 bool_fields = ['is_public', 'inherit_members'] for field in bool_fields: if field in data: value = data[field] if not isinstance(value, bool): errors.append(f"{field} 必須為布林值 (true/false)") # ID 欄位驗證 if 'parent_id' in data: parent_id = data['parent_id'] if parent_id is not None and (not isinstance(parent_id, int) or parent_id <= 0): errors.append("父專案 ID (parent_id) 必須為正整數") return ValidationResult(is_valid=len(errors) == 0, errors=errors, warnings=warnings) @classmethod def validate_query_params(cls, params: Dict[str, Any]) -> ValidationResult: """驗證查詢參數""" errors = [] warnings = [] # 分頁參數驗證 if 'limit' in params: limit = params['limit'] if not isinstance(limit, int) or limit <= 0: errors.append("分頁限制 (limit) 必須為正整數") elif limit > 100: warnings.append("建議分頁限制不超過 100 以確保效能") if 'offset' in params: offset = params['offset'] if not isinstance(offset, int) or offset < 0: errors.append("分頁偏移 (offset) 必須為非負整數") # ID 篩選參數驗證 id_fields = ['project_id', 'tracker_id', 'priority_id', 'assigned_to_id', 'author_id'] for field in id_fields: if field in params: value = params[field] if value is not None and (not isinstance(value, int) or value <= 0): errors.append(f"{field} 必須為正整數") # 特殊處理 status_id(支援 Redmine 的特殊值) if 'status_id' in params: status_id = params['status_id'] if status_id is not None: # 允許正整數或 Redmine 特殊值 ('o' 為開放, 'c' 為關閉) if not (isinstance(status_id, int) and status_id > 0) and status_id not in ['o', 'c']: errors.append("status_id 必須為正整數或 'o'(開放)/'c'(關閉)") # 日期篩選參數驗證 date_fields = ['created_on', 'updated_on'] for field in date_fields: if field in params: date_value = params[field] if date_value and not cls._is_valid_date_filter(date_value): errors.append(f"{field} 日期格式不正確,支援格式:YYYY-MM-DD, >=YYYY-MM-DD, <=YYYY-MM-DD") # 排序參數驗證 if 'sort' in params: sort_value = params['sort'] if sort_value is not None and not isinstance(sort_value, str): errors.append("排序參數 (sort) 必須為文字格式") elif sort_value and not cls._is_valid_sort_field(sort_value): warnings.append(f"排序欄位 '{sort_value}' 可能不被支援") return ValidationResult(is_valid=len(errors) == 0, errors=errors, warnings=warnings) @classmethod def _is_valid_date_filter(cls, date_str: str) -> bool: """驗證日期篩選格式""" if not isinstance(date_str, str): return False return any(pattern.match(date_str) for pattern in cls.DATE_PATTERNS) @classmethod def _is_valid_sort_field(cls, sort_str: str) -> bool: """驗證排序欄位""" # 移除 :desc 或 :asc 後綴 field = sort_str.split(':')[0] # 常見的排序欄位 valid_fields = [ 'id', 'subject', 'status', 'priority', 'author', 'assigned_to', 'created_on', 'updated_on', 'due_date', 'done_ratio', 'project' ] return field in valid_fields @classmethod def get_friendly_error_message(cls, error: Exception, context: str = "") -> str: """將技術錯誤轉換為友好的錯誤訊息""" error_msg = str(error).lower() # HTTP 錯誤訊息轉換 if "401" in error_msg or "unauthorized" in error_msg: return "認證失敗:請檢查 API 金鑰是否正確" elif "403" in error_msg or "forbidden" in error_msg: return "權限不足:您沒有執行此操作的權限" elif "404" in error_msg or "not found" in error_msg: if "issue" in context.lower(): return "找不到指定的議題,請確認議題 ID 是否正確" elif "project" in context.lower(): return "找不到指定的專案,請確認專案 ID 或識別碼是否正確" else: return "找不到指定的資源" elif "422" in error_msg or "unprocessable" in error_msg: return "資料格式錯誤:請檢查輸入的資料是否符合要求" elif "500" in error_msg or "internal server error" in error_msg: return "伺服器內部錯誤:請稍後再試或聯絡系統管理員" elif "timeout" in error_msg: return "請求逾時:網路連線可能不穩定,請稍後再試" elif "connection" in error_msg or "connectionerror" in error_msg: return "連線失敗:請檢查網路連線和 Redmine 伺服器狀態" elif "httperror" in error_msg: return f"HTTP 錯誤:{str(error)}" # 其他常見錯誤 if "json" in error_msg and "decode" in error_msg: return "回應格式錯誤:伺服器回應的資料格式不正確" # 預設錯誤訊息 return f"操作失敗:{str(error)}" def validate_and_clean_data(data: Dict[str, Any], validation_type: str) -> Dict[str, Any]: """驗證並清理資料""" if validation_type == "issue": result = RedmineValidator.validate_issue_data(data) elif validation_type == "project": result = RedmineValidator.validate_project_data(data) elif validation_type == "query": result = RedmineValidator.validate_query_params(data) else: raise ValueError(f"不支援的驗證類型:{validation_type}") if not result.is_valid: raise RedmineValidationError( f"資料驗證失敗:{'; '.join(result.errors)}", errors=result.errors ) # 回傳清理後的資料(移除 None 值和空字串) cleaned_data = {} for key, value in data.items(): if value is not None and value != "": cleaned_data[key] = value return cleaned_data

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