From 56c86561a996095d8c70ba3ceeed2f5b98ff7ef4 Mon Sep 17 00:00:00 2001 From: Olivier Gintrand Date: Thu, 12 Feb 2026 10:41:02 +0100 Subject: [PATCH 1/4] feat: add Assets/CMDB, Relationships and Asset Types management tools Add 22 new MCP tools for Freshservice Asset/CMDB management: Assets: - get_assets, get_asset_by_id, create_asset, update_asset - delete_asset, delete_asset_permanently, restore_asset - search_assets, filter_assets, move_asset - get_asset_components, get_asset_assignment_history - get_asset_requests, get_asset_contracts Relationships: - get_asset_relationships, get_all_relationships - get_relationship_by_id, create_asset_relationships - delete_asset_relationships, get_relationship_types Asset Types: - get_asset_types, get_asset_type_by_id Updated README with new modules, tools tables and examples. --- README.md | 50 ++ src/freshservice_mcp/server.py | 852 +++++++++++++++++++++++++++++++++ 2 files changed, 902 insertions(+) diff --git a/README.md b/README.md index 9b60255..3d8349f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ A powerful MCP (Model Context Protocol) server implementation that seamlessly in - Solution Categories - Solution Folders - Solution Articles +- Assets / CMDB +- Asset Relationships +- Asset Types ## Components & Tools @@ -76,6 +79,43 @@ When using `get_changes` or `filter_changes` with the `query` parameter, **the q - `"planned_start_date:>'2025-07-14'"` - Changes starting after specific date - `"status:3 AND priority:1"` - High priority changes awaiting approval +### Asset / CMDB Management + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `get_assets` | List all assets with pagination | `page`, `per_page`, `include`, `order_by`, `order_type`, `trashed`, `workspace_id` | +| `get_asset_by_id` | Retrieve single asset details | `display_id`, `include` | +| `create_asset` | Create a new asset | `name`, `asset_type_id`, `impact`, `usage_type`, `description`, `type_fields` | +| `update_asset` | Update an existing asset | `display_id`, `asset_fields` | +| `delete_asset` | Delete (trash) an asset | `display_id` | +| `delete_asset_permanently` | Permanently delete a trashed asset | `display_id` | +| `restore_asset` | Restore a trashed asset | `display_id` | +| `search_assets` | Search assets by attributes | `search_query`, `page`, `trashed` | +| `filter_assets` | Filter assets with advanced queries | `filter_query`, `page`, `include` | +| `move_asset` | Move asset to another workspace | `display_id`, `workspace_id`, `agent_id`, `group_id` | +| `get_asset_components` | List hardware components | `display_id` | +| `get_asset_assignment_history` | View user assignment history | `display_id` | +| `get_asset_requests` | List associated tickets | `display_id` | +| `get_asset_contracts` | List associated contracts | `display_id` | + +### Asset Relationships + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `get_asset_relationships` | List relationships for an asset | `display_id` | +| `get_all_relationships` | List all relationships | `page`, `per_page` | +| `get_relationship_by_id` | View a specific relationship | `relationship_id` | +| `create_asset_relationships` | Create relationships in bulk | `relationships` | +| `delete_asset_relationships` | Delete relationships in bulk | `relationship_ids` | +| `get_relationship_types` | List available relationship types | None | + +### Asset Types + +| Tool | Description | Key Parameters | +|------|-------------|----------------| +| `get_asset_types` | List all asset types | `page`, `per_page` | +| `get_asset_type_by_id` | View a specific asset type | `asset_type_id` | + ## Getting Started ### Installing via Smithery @@ -140,6 +180,16 @@ Once configured, you can ask Claude to perform operations like: - "Show asset details for laptop with asset tag 'LT-2023-087'" - "Create a solution article about password reset procedures" +**Assets / CMDB:** +- "List all assets in the CMDB" +- "Create a new hardware asset named 'Dell Latitude 5540' with asset type 'Laptop'" +- "Search assets with serial number 'HSN12345'" +- "Filter assets by state 'IN USE' in department 5" +- "Show all components of asset #42 (CPU, memory, disk, etc.)" +- "Show the assignment history for asset #115" +- "List all relationships for asset #42" +- "Move asset #99 to workspace 3" + ## Testing For testing purposes, you can start the server manually: diff --git a/src/freshservice_mcp/server.py b/src/freshservice_mcp/server.py index b27c96c..4f671e1 100644 --- a/src/freshservice_mcp/server.py +++ b/src/freshservice_mcp/server.py @@ -3232,6 +3232,858 @@ async def publish_solution_article(article_id: int) -> Dict[str, Any]: "error": f"Unexpected error occurred: {str(e)}" } + +# ============================================================================ +# ASSETS / CMDB MANAGEMENT +# ============================================================================ + +#GET ASSETS +@mcp.tool() +async def get_assets( + page: Optional[int] = 1, + per_page: Optional[int] = 30, + include: Optional[str] = None, + order_by: Optional[str] = None, + order_type: Optional[str] = None, + trashed: Optional[bool] = False, + workspace_id: Optional[int] = None +) -> Dict[str, Any]: + """Get all assets from Freshservice with pagination support. + + Args: + page: Page number (default: 1) + per_page: Number of items per page (1-100, default: 30) + include: Embed additional details. Use 'type_fields' to get asset type specific fields (costs 1 extra API credit) + order_by: Sort field: 'id', 'created_at', or 'updated_at' (default: created_at) + order_type: Sort order: 'asc' or 'desc' (default: desc) + trashed: If True, return only trashed assets (default: False) + workspace_id: Filter by workspace ID. Use 0 for all workspaces. If not provided, returns assets from primary workspace only. + """ + if page < 1: + return {"error": "Page number must be greater than 0"} + + if per_page < 1 or per_page > 100: + return {"error": "Page size must be between 1 and 100"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets" + + params = { + "page": page, + "per_page": per_page + } + + if include: + params["include"] = include + if order_by: + params["order_by"] = order_by + if order_type: + params["order_type"] = order_type + if trashed: + params["trashed"] = "true" + if workspace_id is not None: + params["workspace_id"] = workspace_id + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + assets = response.json() + + return { + "assets": assets, + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET BY ID +@mcp.tool() +async def get_asset_by_id( + display_id: int, + include: Optional[str] = None +) -> Dict[str, Any]: + """Get a specific asset by its display ID in Freshservice. + + Args: + display_id: The display ID of the asset to retrieve + include: Embed additional details. Use 'type_fields' to get asset type specific fields (costs 1 extra API credit) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" + + params = {} + if include: + params["include"] = include + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#CREATE ASSET +@mcp.tool() +async def create_asset( + name: str, + asset_type_id: int, + asset_tag: Optional[str] = None, + impact: Optional[str] = "low", + usage_type: Optional[str] = "permanent", + description: Optional[str] = None, + user_id: Optional[int] = None, + location_id: Optional[int] = None, + department_id: Optional[int] = None, + agent_id: Optional[int] = None, + group_id: Optional[int] = None, + assigned_on: Optional[str] = None, + workspace_id: Optional[int] = None, + type_fields: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """Create a new asset in Freshservice. + + Args: + name: Name of the asset (MANDATORY) + asset_type_id: ID of the asset type (MANDATORY) + asset_tag: Asset tag (e.g., 'ASSET-9') + impact: Impact level: 'low', 'medium', or 'high' (default: 'low') + usage_type: Usage type: 'permanent' or 'loaner' (default: 'permanent') + description: Description of the asset + user_id: ID of the user the asset is assigned to (Used By) + location_id: ID of the associated location + department_id: ID of the associated department + agent_id: ID of the agent managing the asset (Managed By) + group_id: ID of the agent group managing the asset (Managed By Group) + assigned_on: Date when asset was assigned (ISO format) + workspace_id: Workspace ID (applicable only to accounts with workspaces) + type_fields: Asset type specific fields (e.g., product_id, vendor_id, serial_number, cost, etc.) + """ + if impact and impact not in ("low", "medium", "high"): + return {"error": "Invalid impact value. Must be 'low', 'medium', or 'high'"} + + if usage_type and usage_type not in ("permanent", "loaner"): + return {"error": "Invalid usage_type value. Must be 'permanent' or 'loaner'"} + + data: Dict[str, Any] = { + "name": name, + "asset_type_id": asset_type_id + } + + if asset_tag: + data["asset_tag"] = asset_tag + if impact: + data["impact"] = impact + if usage_type: + data["usage_type"] = usage_type + if description: + data["description"] = description + if user_id is not None: + data["user_id"] = user_id + if location_id is not None: + data["location_id"] = location_id + if department_id is not None: + data["department_id"] = department_id + if agent_id is not None: + data["agent_id"] = agent_id + if group_id is not None: + data["group_id"] = group_id + if assigned_on: + data["assigned_on"] = assigned_on + if workspace_id is not None: + data["workspace_id"] = workspace_id + if type_fields: + data["type_fields"] = type_fields + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return { + "success": True, + "message": "Asset created successfully", + "asset": response.json() + } + except httpx.HTTPStatusError as e: + error_message = f"Failed to create asset: {str(e)}" + try: + error_details = e.response.json() + if "errors" in error_details: + error_message = f"Validation errors: {error_details['errors']}" + except Exception: + pass + return {"success": False, "error": error_message} + except Exception as e: + return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + + +#UPDATE ASSET +@mcp.tool() +async def update_asset( + display_id: int, + asset_fields: Dict[str, Any] +) -> Dict[str, Any]: + """Update an existing asset in Freshservice. + + Args: + display_id: The display ID of the asset to update + asset_fields: Dictionary of fields to update. Supported fields include: + - name: Name of the asset + - asset_type_id: ID of the asset type + - asset_tag: Asset tag + - impact: 'low', 'medium', or 'high' + - usage_type: 'permanent' or 'loaner' + - description: Description + - user_id: User ID (Used By) + - location_id: Location ID + - department_id: Department ID + - agent_id: Agent ID (Managed By) + - group_id: Agent group ID (Managed By Group) + - assigned_on: Assignment date (ISO format) + - type_fields: Asset type specific fields dict + + Note: workspace_id cannot be updated here. Use move_asset instead. + """ + if not asset_fields: + return {"error": "No fields provided for update"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=asset_fields) + response.raise_for_status() + return { + "success": True, + "message": "Asset updated successfully", + "asset": response.json() + } + except httpx.HTTPStatusError as e: + error_message = f"Failed to update asset: {str(e)}" + try: + error_details = e.response.json() + if "errors" in error_details: + error_message = f"Validation errors: {error_details['errors']}" + except Exception: + pass + return {"success": False, "error": error_message} + except Exception as e: + return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + + +#DELETE ASSET (move to trash) +@mcp.tool() +async def delete_asset(display_id: int) -> str: + """Delete an asset in Freshservice (moves it to trash). + + Args: + display_id: The display ID of the asset to delete + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.delete(url, headers=headers) + + if response.status_code == 204: + return "Asset deleted successfully (moved to trash)" + elif response.status_code == 404: + return "Error: Asset not found" + else: + try: + response_data = response.json() + return f"Error: {response_data.get('error', 'Failed to delete asset')}" + except ValueError: + return "Error: Unexpected response format" + + +#DELETE ASSET PERMANENTLY +@mcp.tool() +async def delete_asset_permanently(display_id: int) -> str: + """Permanently delete an asset from Freshservice. This action cannot be undone. + + Args: + display_id: The display ID of the trashed asset to permanently delete + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/delete_forever" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers) + + if response.status_code == 204: + return "Asset permanently deleted" + elif response.status_code == 404: + return "Error: Asset not found" + else: + try: + response_data = response.json() + return f"Error: {response_data.get('error', 'Failed to permanently delete asset')}" + except ValueError: + return "Error: Unexpected response format" + + +#RESTORE ASSET +@mcp.tool() +async def restore_asset(display_id: int) -> str: + """Restore a previously deleted (trashed) asset in Freshservice. + + Args: + display_id: The display ID of the trashed asset to restore + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/restore" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers) + + if response.status_code == 204: + return "Asset restored successfully" + elif response.status_code == 404: + return "Error: Asset not found in trash" + else: + try: + response_data = response.json() + return f"Error: {response_data.get('error', 'Failed to restore asset')}" + except ValueError: + return "Error: Unexpected response format" + + +#SEARCH ASSETS +@mcp.tool() +async def search_assets( + search_query: str, + page: Optional[int] = 1, + trashed: Optional[bool] = False +) -> Dict[str, Any]: + """Search assets in Freshservice using asset attributes. + + Args: + search_query: Search query string using asset fields. + Supported fields: name, asset_tag, serial_number, mac_addresses, ip_addresses, uuid, item_id, imei_number. + Examples: "name:'dell'", "serial_number:'HSN123'", "asset_tag:'ASSET-65'" + Note: The query will be automatically URL-encoded and wrapped in double quotes. + page: Page number (default: 1). 30 results per page. + trashed: If True, search in trashed assets (default: False) + """ + encoded_search = urllib.parse.quote(f'"{search_query}"') + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets?search={encoded_search}&page={page}" + + if trashed: + url += "&trashed=true" + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + total_count = response.headers.get('X-Total-Count', None) + + result = response.json() + result["pagination"] = { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + } + if total_count: + result["total_count"] = int(total_count) + return result + + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#FILTER ASSETS +@mcp.tool() +async def filter_assets( + filter_query: str, + page: Optional[int] = 1, + include: Optional[str] = None +) -> Dict[str, Any]: + """Filter assets in Freshservice using asset attributes. + + Args: + filter_query: Filter query string using asset fields. + Supported fields: workspace_id, asset_type_id, department_id, location_id, + asset_state, user_id, agent_id, name, asset_tag, created_at, updated_at, + serial_number, mac_addresses, ip_addresses, uuid, item_id, imei_number, device42_id. + Operators: AND, OR, parentheses (), :> (>=), :< (<=) + Examples: + "asset_state:'IN USE'" + "asset_state:'IN STOCK' AND created_at:>'2023-01-01'" + "department_id:5 AND location_id:3" + "asset_type_id:25 AND agent_id:null" + Note: The query will be automatically URL-encoded and wrapped in double quotes. + page: Page number (default: 1). 30 results per page, max 40 pages. + include: Embed additional details. Use 'type_fields' for asset type specific fields. + """ + encoded_filter = urllib.parse.quote(f'"{filter_query}"') + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets?filter={encoded_filter}&page={page}" + + if include: + url += f"&include={include}" + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + total_count = response.headers.get('X-Total-Count', None) + + result = response.json() + result["pagination"] = { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + } + if total_count: + result["total_count"] = int(total_count) + return result + + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#MOVE ASSET TO WORKSPACE +@mcp.tool() +async def move_asset( + display_id: int, + workspace_id: int, + agent_id: Optional[int] = None, + group_id: Optional[int] = None +) -> Dict[str, Any]: + """Move an asset to a different workspace in Freshservice. + + Note: This endpoint is applicable only to accounts with workspaces. + + Args: + display_id: The display ID of the asset to move + workspace_id: The target workspace ID + agent_id: Optional new agent ID for the asset + group_id: Optional new group ID for the asset + """ + data: Dict[str, Any] = {"workspace_id": workspace_id} + if agent_id is not None: + data["agent_id"] = agent_id + if group_id is not None: + data["group_id"] = group_id + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/move_workspace" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return { + "success": True, + "message": "Asset moved successfully", + "asset": response.json() + } + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to move asset: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to move asset: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET COMPONENTS +@mcp.tool() +async def get_asset_components(display_id: int) -> Dict[str, Any]: + """Get all components of an asset in Freshservice (e.g., Processor, Memory, Disk, etc.). + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/components" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset components: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset components: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET ASSIGNMENT HISTORY +@mcp.tool() +async def get_asset_assignment_history(display_id: int) -> Dict[str, Any]: + """Get the user assignment history for a specific asset in Freshservice. + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/assignment-history" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch assignment history: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch assignment history: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET REQUESTS +@mcp.tool() +async def get_asset_requests(display_id: int) -> Dict[str, Any]: + """List all associated requests (tickets) for a specific asset in Freshservice. + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/requests" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset requests: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset requests: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET CONTRACTS +@mcp.tool() +async def get_asset_contracts(display_id: int) -> Dict[str, Any]: + """List all associated contracts for a specific asset in Freshservice. + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/contracts" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset contracts: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset contracts: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +# ============================================================================ +# ASSET RELATIONSHIPS +# ============================================================================ + +#GET ASSET RELATIONSHIPS +@mcp.tool() +async def get_asset_relationships(display_id: int) -> Dict[str, Any]: + """List all relationships for a specific asset in Freshservice. + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/relationships" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset relationships: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset relationships: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ALL RELATIONSHIPS +@mcp.tool() +async def get_all_relationships(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: + """List all asset relationships in the Freshservice account. + + Args: + page: Page number (default: 1) + per_page: Number of items per page (1-100, default: 30) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships" + params = {"page": page, "per_page": per_page} + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + return { + "relationships": response.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET RELATIONSHIP BY ID +@mcp.tool() +async def get_relationship_by_id(relationship_id: int) -> Dict[str, Any]: + """View a specific relationship by its ID in Freshservice. + + Args: + relationship_id: The ID of the relationship + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships/{relationship_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch relationship: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch relationship: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#CREATE RELATIONSHIPS IN BULK +@mcp.tool() +async def create_asset_relationships( + relationships: List[Dict[str, Any]] +) -> Dict[str, Any]: + """Create asset relationships in bulk in Freshservice. + + Args: + relationships: List of relationship objects. Each object should contain: + - relationship_type_id: ID of the relationship type + - primary_id: Display ID of the primary asset + - primary_type: Type of the primary item (e.g., 'asset') + - secondary_id: Display ID of the secondary asset + - secondary_type: Type of the secondary item (e.g., 'asset') + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json={"relationships": relationships}) + response.raise_for_status() + return { + "success": True, + "message": "Relationships created successfully", + "data": response.json() + } + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to create relationships: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to create relationships: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#DELETE RELATIONSHIPS IN BULK +@mcp.tool() +async def delete_asset_relationships( + relationship_ids: List[int] +) -> str: + """Delete asset relationships in bulk in Freshservice. + + Args: + relationship_ids: List of relationship IDs to delete + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.request("DELETE", url, headers=headers, json={"ids": relationship_ids}) + + if response.status_code == 204: + return "Relationships deleted successfully" + else: + try: + return f"Error: {response.json()}" + except Exception: + return f"Error: Unexpected response (status {response.status_code})" + except Exception as e: + return f"Error: An unexpected error occurred: {str(e)}" + + +#GET RELATIONSHIP TYPES +@mcp.tool() +async def get_relationship_types() -> Dict[str, Any]: + """List all relationship types available in Freshservice. + These define the kind of relationships between assets (e.g., 'Used By', 'Depends On', etc.). + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationship_types" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch relationship types: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch relationship types: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +# ============================================================================ +# ASSET TYPES +# ============================================================================ + +#GET ASSET TYPES +@mcp.tool() +async def get_asset_types(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: + """List all asset types in Freshservice (e.g., Hardware, Software, etc.). + + Args: + page: Page number (default: 1) + per_page: Number of items per page (1-100, default: 30) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/asset_types" + params = {"page": page, "per_page": per_page} + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + return { + "asset_types": response.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET TYPE BY ID +@mcp.tool() +async def get_asset_type_by_id(asset_type_id: int) -> Dict[str, Any]: + """Get a specific asset type by ID in Freshservice. + + Args: + asset_type_id: The ID of the asset type + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/asset_types/{asset_type_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset type: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset type: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + # GET AUTH HEADERS def get_auth_headers(): return { From c49e9eb94e63ab4387301ee53d55fb1dde342033 Mon Sep 17 00:00:00 2001 From: Olivier Gintrand Date: Thu, 12 Feb 2026 12:02:57 +0100 Subject: [PATCH 2/4] fix: correct relationship endpoints and add job status tracking - Fix create_asset_relationships: use /api/v2/relationships/bulk-create (async job) - Fix delete_asset_relationships: use query params ?ids=x,y instead of JSON body - Add get_job_status tool to track async bulk operations - Support all entity types: asset, requester, agent, department, software --- README.md | 55 ++++++------ src/freshservice_mcp/server.py | 63 ++++++++++--- uv.lock | 156 ++++++++++++++++----------------- 3 files changed, 161 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 3d8349f..16b3b54 100644 --- a/README.md +++ b/README.md @@ -17,23 +17,23 @@ A powerful MCP (Model Context Protocol) server implementation that seamlessly in **This MCP server currently supports operations across a wide range of Freshservice modules**: -- Tickets -- Changes -- Conversations -- Products -- Requesters -- Agents -- Agent Groups -- Requester Groups -- Canned Responses -- Canned Response Folders -- Workspaces -- Solution Categories -- Solution Folders -- Solution Articles -- Assets / CMDB -- Asset Relationships -- Asset Types +- Tickets +- Changes +- Conversations +- Products +- Requesters +- Agents +- Agent Groups +- Requester Groups +- Canned Responses +- Canned Response Folders +- Workspaces +- Solution Categories +- Solution Folders +- Solution Articles +- Assets / CMDB +- Asset Relationships +- Asset Types ## Components & Tools @@ -42,7 +42,7 @@ The server provides a comprehensive toolkit for Freshservice operations: ### Ticket Management | Tool | Description | Key Parameters | -|------|-------------|----------------| +| ---- | ----------- | -------------- | | `create_ticket` | Create new service tickets | `subject`, `description`, `source`, `priority`, `status`, `email` | | `update_ticket` | Update existing tickets | `ticket_id`, `updates` | | `delete_ticket` | Remove tickets | `ticket_id` | @@ -54,7 +54,7 @@ The server provides a comprehensive toolkit for Freshservice operations: ### Change Management | Tool | Description | Key Parameters | -|------|-------------|----------------| +| ---- | ----------- | -------------- | | `get_changes` | List all changes with pagination | `page`, `per_page`, `query` | | `filter_changes` | Filter changes with advanced queries | `query`, `page`, `per_page` | | `get_change_by_id` | Retrieve single change details | `change_id` | @@ -69,12 +69,13 @@ The server provides a comprehensive toolkit for Freshservice operations: When using `get_changes` or `filter_changes` with the `query` parameter, **the query string must be wrapped in double quotes** for the Freshservice API to work correctly: -✅ **CORRECT**: `"status:3"`, `"approval_status:1 AND status:<6"` +✅ **CORRECT**: `"status:3"`, `"approval_status:1 AND status:<6"` ❌ **WRONG**: `status:3` (will cause 500 Internal Server Error) **Common Query Examples:** + - `"status:3"` - Changes awaiting approval -- `"approval_status:1"` - Approved changes +- `"approval_status:1"` - Approved changes - `"approval_status:1 AND status:<6"` - Approved changes that are not closed - `"planned_start_date:>'2025-07-14'"` - Changes starting after specific date - `"status:3 AND priority:1"` - High priority changes awaiting approval @@ -82,7 +83,7 @@ When using `get_changes` or `filter_changes` with the `query` parameter, **the q ### Asset / CMDB Management | Tool | Description | Key Parameters | -|------|-------------|----------------| +| ---- | ----------- | -------------- | | `get_assets` | List all assets with pagination | `page`, `per_page`, `include`, `order_by`, `order_type`, `trashed`, `workspace_id` | | `get_asset_by_id` | Retrieve single asset details | `display_id`, `include` | | `create_asset` | Create a new asset | `name`, `asset_type_id`, `impact`, `usage_type`, `description`, `type_fields` | @@ -101,7 +102,7 @@ When using `get_changes` or `filter_changes` with the `query` parameter, **the q ### Asset Relationships | Tool | Description | Key Parameters | -|------|-------------|----------------| +| ---- | ----------- | -------------- | | `get_asset_relationships` | List relationships for an asset | `display_id` | | `get_all_relationships` | List all relationships | `page`, `per_page` | | `get_relationship_by_id` | View a specific relationship | `relationship_id` | @@ -112,7 +113,7 @@ When using `get_changes` or `filter_changes` with the `query` parameter, **the q ### Asset Types | Tool | Description | Key Parameters | -|------|-------------|----------------| +| ---- | ----------- | -------------- | | `get_asset_types` | List all asset types | `page`, `per_page` | | `get_asset_type_by_id` | View a specific asset type | `asset_type_id` | @@ -159,6 +160,7 @@ npx -y @smithery/cli install @effytech/freshservice_mcp --client claude } } ``` + **Important**: Replace `` with your actual API key and `` with your domain (e.g., `yourcompany.freshservice.com`) ## Example Operations @@ -166,21 +168,25 @@ npx -y @smithery/cli install @effytech/freshservice_mcp --client claude Once configured, you can ask Claude to perform operations like: **Tickets:** + - "Create a new incident ticket with subject 'Network connectivity issue in Marketing department' and description 'Users unable to connect to Wi-Fi in Marketing area', set priority to high" - "List all critical incidents reported in the last 24 hours" - "Update ticket #12345 status to resolved" **Changes:** + - "Create a change request for scheduled server maintenance next Tuesday at 2 AM" - "Update the status of change request #45678 to 'Approved'" - "Close change #5092 with result explanation 'Successfully deployed to production. All tests passed.'" - "List all pending changes" **Other Operations:** + - "Show asset details for laptop with asset tag 'LT-2023-087'" - "Create a solution article about password reset procedures" **Assets / CMDB:** + - "List all assets in the CMDB" - "Create a new hardware asset named 'Dell Latitude 5540' with asset type 'Laptop'" - "Search assets with serial number 'HSN12345'" @@ -205,7 +211,6 @@ uvx freshservice-mcp --env FRESHSERVICE_APIKEY= --env FRESHSERVICE - Check API rate limits and quotas - Verify the `uvx` command is available in your PATH - ## License This MCP server is licensed under the MIT License. See the LICENSE file in the project repository for full details. diff --git a/src/freshservice_mcp/server.py b/src/freshservice_mcp/server.py index 4f671e1..90e3a86 100644 --- a/src/freshservice_mcp/server.py +++ b/src/freshservice_mcp/server.py @@ -3936,25 +3936,32 @@ async def create_asset_relationships( ) -> Dict[str, Any]: """Create asset relationships in bulk in Freshservice. + This is an asynchronous operation performed via background jobs. + The response contains a job_id which can be used with get_job_status() to track progress. + Supported primary_type/secondary_type values: 'asset', 'requester', 'agent', 'department', 'software'. + Args: relationships: List of relationship objects. Each object should contain: - - relationship_type_id: ID of the relationship type - - primary_id: Display ID of the primary asset - - primary_type: Type of the primary item (e.g., 'asset') - - secondary_id: Display ID of the secondary asset - - secondary_type: Type of the secondary item (e.g., 'asset') + - relationship_type_id: ID of the relationship type (MANDATORY) + - primary_id: ID of the primary entity (MANDATORY) + - primary_type: Type of the primary entity: 'asset', 'requester', 'agent', 'department', 'software' (MANDATORY) + - secondary_id: ID of the secondary entity (MANDATORY) + - secondary_type: Type of the secondary entity: 'asset', 'requester', 'agent', 'department', 'software' (MANDATORY) """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships/bulk-create" headers = get_auth_headers() async with httpx.AsyncClient() as client: try: response = await client.post(url, headers=headers, json={"relationships": relationships}) response.raise_for_status() + result = response.json() return { "success": True, - "message": "Relationships created successfully", - "data": response.json() + "message": "Bulk relationship creation job submitted. Use get_job_status() with the job_id to track progress.", + "job_id": result.get("job_id"), + "href": result.get("href"), + "data": result } except httpx.HTTPStatusError as e: try: @@ -3975,12 +3982,13 @@ async def delete_asset_relationships( Args: relationship_ids: List of relationship IDs to delete """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships" + ids_param = ",".join(str(rid) for rid in relationship_ids) + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships?ids={ids_param}" headers = get_auth_headers() async with httpx.AsyncClient() as client: try: - response = await client.request("DELETE", url, headers=headers, json={"ids": relationship_ids}) + response = await client.delete(url, headers=headers) if response.status_code == 204: return "Relationships deleted successfully" @@ -3993,6 +4001,41 @@ async def delete_asset_relationships( return f"Error: An unexpected error occurred: {str(e)}" +#GET BACKGROUND JOB STATUS +@mcp.tool() +async def get_job_status(job_id: str) -> Dict[str, Any]: + """Get the status of a background job in Freshservice. + + Use this to track the progress of async operations like bulk relationship creation. + Possible status values: 'queued', 'in progress', 'partial', 'success', 'failed'. + - queued: Job is queued and ready to be executed + - in progress: Job execution started + - partial: Job completed with some successes and some failures + - success: All operations completed successfully + - failed: No operations succeeded + + Note: The job status URL is valid for one hour after creation. + + Args: + job_id: The job ID returned by a bulk operation (e.g., create_asset_relationships) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/jobs/{job_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch job status: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch job status: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + #GET RELATIONSHIP TYPES @mcp.tool() async def get_relationship_types() -> Dict[str, Any]: diff --git a/uv.lock b/uv.lock index 23f664f..00de32e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,14 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.13" [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -19,9 +19,9 @@ dependencies = [ { name = "idna" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] [[package]] @@ -33,18 +33,18 @@ dependencies = [ { name = "packaging" }, { name = "pyproject-hooks" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701, upload-time = "2024-10-06T17:22:25.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950 }, + { url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950, upload-time = "2024-10-06T17:22:23.299Z" }, ] [[package]] name = "certifi" version = "2025.1.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, ] [[package]] @@ -54,23 +54,23 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "freshservice-mcp" -version = "0.1.0" +version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "build" }, @@ -91,9 +91,9 @@ requires-dist = [ name = "h11" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, ] [[package]] @@ -104,9 +104,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/45/ad3e1b4d448f22c0cff4f5692f5ed0666658578e358b8d58a19846048059/httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad", size = 85385 } +sdist = { url = "https://files.pythonhosted.org/packages/9f/45/ad3e1b4d448f22c0cff4f5692f5ed0666658578e358b8d58a19846048059/httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad", size = 85385, upload-time = "2025-04-11T14:42:46.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/8d/f052b1e336bb2c1fc7ed1aaed898aa570c0b61a09707b108979d9fc6e308/httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be", size = 78732 }, + { url = "https://files.pythonhosted.org/packages/18/8d/f052b1e336bb2c1fc7ed1aaed898aa570c0b61a09707b108979d9fc6e308/httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be", size = 78732, upload-time = "2025-04-11T14:42:44.896Z" }, ] [[package]] @@ -119,27 +119,27 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "httpx-sse" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] @@ -149,9 +149,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] @@ -168,9 +168,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } +sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031, upload-time = "2025-03-27T16:46:32.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, + { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077, upload-time = "2025-03-27T16:46:29.919Z" }, ] [package.optional-dependencies] @@ -183,18 +183,18 @@ cli = [ name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "packaging" version = "24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] [[package]] @@ -207,9 +207,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 } +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513, upload-time = "2025-04-08T13:27:06.399Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 }, + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591, upload-time = "2025-04-08T13:27:03.789Z" }, ] [[package]] @@ -219,25 +219,25 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 } +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395, upload-time = "2025-04-02T09:49:41.8Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 }, - { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 }, - { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 }, - { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 }, - { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 }, - { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 }, - { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 }, - { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 }, - { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 }, - { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 }, - { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 }, - { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 }, - { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 }, - { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 }, - { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 }, - { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 }, - { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551, upload-time = "2025-04-02T09:47:51.648Z" }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785, upload-time = "2025-04-02T09:47:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758, upload-time = "2025-04-02T09:47:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109, upload-time = "2025-04-02T09:47:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159, upload-time = "2025-04-02T09:47:58.088Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222, upload-time = "2025-04-02T09:47:59.591Z" }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980, upload-time = "2025-04-02T09:48:01.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840, upload-time = "2025-04-02T09:48:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518, upload-time = "2025-04-02T09:48:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025, upload-time = "2025-04-02T09:48:06.226Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991, upload-time = "2025-04-02T09:48:08.114Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262, upload-time = "2025-04-02T09:48:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626, upload-time = "2025-04-02T09:48:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590, upload-time = "2025-04-02T09:48:12.861Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963, upload-time = "2025-04-02T09:48:14.553Z" }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896, upload-time = "2025-04-02T09:48:16.222Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810, upload-time = "2025-04-02T09:48:17.97Z" }, ] [[package]] @@ -248,36 +248,36 @@ dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550, upload-time = "2025-02-27T10:10:32.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, ] [[package]] name = "pygments" version = "2.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] [[package]] name = "pyproject-hooks" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 }, + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] [[package]] name = "python-dotenv" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, ] [[package]] @@ -288,27 +288,27 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] @@ -319,9 +319,9 @@ dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376, upload-time = "2024-12-25T09:09:30.616Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120, upload-time = "2024-12-25T09:09:26.761Z" }, ] [[package]] @@ -331,9 +331,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] [[package]] @@ -346,18 +346,18 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } +sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711, upload-time = "2025-02-27T19:17:34.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, + { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061, upload-time = "2025-02-27T19:17:32.111Z" }, ] [[package]] name = "typing-extensions" version = "4.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, ] [[package]] @@ -367,9 +367,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222, upload-time = "2025-02-25T17:27:59.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" }, ] [[package]] @@ -380,7 +380,7 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/37/dd92f1f9cedb5eaf74d9999044306e06abe65344ff197864175dbbd91871/uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc", size = 76755 } +sdist = { url = "https://files.pythonhosted.org/packages/86/37/dd92f1f9cedb5eaf74d9999044306e06abe65344ff197864175dbbd91871/uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc", size = 76755, upload-time = "2025-04-13T13:48:04.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/38/a5801450940a858c102a7ad9e6150146a25406a119851c993148d56ab041/uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065", size = 62404 }, + { url = "https://files.pythonhosted.org/packages/5f/38/a5801450940a858c102a7ad9e6150146a25406a119851c993148d56ab041/uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065", size = 62404, upload-time = "2025-04-13T13:48:02.408Z" }, ] From c187b516e9a228bc4d7eed53659a921ac3878b63 Mon Sep 17 00:00:00 2001 From: Olivier Gintrand Date: Thu, 12 Feb 2026 14:50:26 +0100 Subject: [PATCH 3/4] fix: refactor update_change with explicit parameters for planning fields, assets, and categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: update_change signature changed from generic Dict to explicit params Problems fixed: - update_change used a generic Dict parameter making it impossible for AI/MCP to discover planning_fields (reason_for_change, change_impact, rollout_plan, backout_plan) — these are now explicit parameters - planning fields passed as top-level fields were sent raw to API and rejected (they must be wrapped in planning_fields.{field}.description structure) - close_change relied on Dict mutation via .pop() — now calls update_change with explicit keyword arguments - create_change and update_change were missing category, sub_category, item_category fields - Neither create_change nor update_change supported assets association - Docstrings now document all enum values for priority, impact, status, risk, change_type --- src/freshservice_mcp/server.py | 201 +++++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 37 deletions(-) diff --git a/src/freshservice_mcp/server.py b/src/freshservice_mcp/server.py index 90e3a86..7813ded 100644 --- a/src/freshservice_mcp/server.py +++ b/src/freshservice_mcp/server.py @@ -522,15 +522,44 @@ async def create_change( group_id: Optional[int] = None, agent_id: Optional[int] = None, department_id: Optional[int] = None, + category: Optional[str] = None, + sub_category: Optional[str] = None, + item_category: Optional[str] = None, planned_start_date: Optional[str] = None, planned_end_date: Optional[str] = None, reason_for_change: Optional[str] = None, change_impact: Optional[str] = None, rollout_plan: Optional[str] = None, backout_plan: Optional[str] = None, - custom_fields: Optional[Dict[str, Any]] = None + custom_fields: Optional[Dict[str, Any]] = None, + assets: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: - """Create a new change in Freshservice.""" + """Create a new change in Freshservice. + + Args: + requester_id: Unique identifier of the initiator of the change (MANDATORY) + subject: Change subject (MANDATORY) + description: HTML content of the change (MANDATORY) + priority: Priority (1=Low, 2=Medium, 3=High, 4=Urgent) (MANDATORY) + impact: Impact (1=Low, 2=Medium, 3=High) (MANDATORY) + status: Status (1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, 5=Pending Review, 6=Closed) (MANDATORY) + risk: Risk (1=Low, 2=Medium, 3=High, 4=Very High) (MANDATORY) + change_type: Type (1=Minor, 2=Standard, 3=Major, 4=Emergency) (MANDATORY) + group_id: Agent group ID + agent_id: Agent ID + department_id: Department ID + category: Category of the change + sub_category: Sub-category of the change + item_category: Item category of the change + planned_start_date: Planned start date (ISO format) + planned_end_date: Planned end date (ISO format) + reason_for_change: Planning field - Reason for change (text/HTML) + change_impact: Planning field - Impact analysis (text/HTML) + rollout_plan: Planning field - Rollout plan (text/HTML) + backout_plan: Planning field - Backout plan (text/HTML) + custom_fields: Custom fields key-value pairs + assets: List of assets to associate, e.g. [{"display_id": 1}, {"display_id": 2}] + """ try: priority_val = int(priority) @@ -565,6 +594,12 @@ async def create_change( data["agent_id"] = agent_id if department_id: data["department_id"] = department_id + if category: + data["category"] = category + if sub_category: + data["sub_category"] = sub_category + if item_category: + data["item_category"] = item_category if planned_start_date: data["planned_start_date"] = planned_start_date if planned_end_date: @@ -595,6 +630,9 @@ async def create_change( if custom_fields: data["custom_fields"] = custom_fields + if assets: + data["assets"] = assets + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes" headers = get_auth_headers() @@ -614,46 +652,134 @@ async def create_change( #UPDATE CHANGE @mcp.tool() -async def update_change(change_id: int, change_fields: Dict[str, Any]) -> Dict[str, Any]: - """Update an existing change in Freshservice. +async def update_change( + change_id: int, + subject: Optional[str] = None, + description: Optional[str] = None, + priority: Optional[Union[int, str]] = None, + impact: Optional[Union[int, str]] = None, + status: Optional[Union[int, str]] = None, + risk: Optional[Union[int, str]] = None, + change_type: Optional[Union[int, str]] = None, + group_id: Optional[int] = None, + agent_id: Optional[int] = None, + department_id: Optional[int] = None, + category: Optional[str] = None, + sub_category: Optional[str] = None, + item_category: Optional[str] = None, + planned_start_date: Optional[str] = None, + planned_end_date: Optional[str] = None, + reason_for_change: Optional[str] = None, + change_impact: Optional[str] = None, + rollout_plan: Optional[str] = None, + backout_plan: Optional[str] = None, + custom_fields: Optional[Dict[str, Any]] = None, + assets: Optional[List[Dict[str, Any]]] = None +) -> Dict[str, Any]: + """Update an existing change in Freshservice. - To update the change result explanation when closing a change: - change_fields = { - "status": 6, # Closed - "custom_fields": { - "change_result_explanation": "Your explanation here" - } - } + Args: + change_id: The ID of the change to update + subject: Change subject + description: HTML content of the change + priority: Priority (1=Low, 2=Medium, 3=High, 4=Urgent) + impact: Impact (1=Low, 2=Medium, 3=High) + status: Status (1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, 5=Pending Review, 6=Closed) + risk: Risk (1=Low, 2=Medium, 3=High, 4=Very High) + change_type: Type (1=Minor, 2=Standard, 3=Major, 4=Emergency) + group_id: Agent group ID + agent_id: Agent ID + department_id: Department ID + category: Category of the change + sub_category: Sub-category of the change + item_category: Item category of the change + planned_start_date: Planned start date (ISO format) + planned_end_date: Planned end date (ISO format) + reason_for_change: Planning field - Reason for change (text/HTML) + change_impact: Planning field - Impact analysis (text/HTML) + rollout_plan: Planning field - Rollout plan (text/HTML) + backout_plan: Planning field - Backout plan (text/HTML) + custom_fields: Custom fields key-value pairs + assets: List of assets to associate, e.g. [{"display_id": 1}, {"display_id": 2}] """ - if not change_fields: - return {"error": "No fields provided for update"} - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" headers = get_auth_headers() - # Extract special fields - custom_fields = change_fields.pop('custom_fields', {}) - planning_fields = change_fields.pop('planning_fields', {}) - update_data = {} - # Add regular fields - for field, value in change_fields.items(): - update_data[field] = value + # Add regular fields if provided + if subject is not None: + update_data["subject"] = subject + if description is not None: + update_data["description"] = description + if group_id is not None: + update_data["group_id"] = group_id + if agent_id is not None: + update_data["agent_id"] = agent_id + if department_id is not None: + update_data["department_id"] = department_id + if category is not None: + update_data["category"] = category + if sub_category is not None: + update_data["sub_category"] = sub_category + if item_category is not None: + update_data["item_category"] = item_category + if planned_start_date is not None: + update_data["planned_start_date"] = planned_start_date + if planned_end_date is not None: + update_data["planned_end_date"] = planned_end_date + + # Handle enum fields with validation + if priority is not None: + try: + update_data["priority"] = int(priority) + except ValueError: + return {"error": f"Invalid priority value: {priority}"} + if impact is not None: + try: + update_data["impact"] = int(impact) + except ValueError: + return {"error": f"Invalid impact value: {impact}"} + if status is not None: + try: + update_data["status"] = int(status) + except ValueError: + return {"error": f"Invalid status value: {status}"} + if risk is not None: + try: + update_data["risk"] = int(risk) + except ValueError: + return {"error": f"Invalid risk value: {risk}"} + if change_type is not None: + try: + update_data["change_type"] = int(change_type) + except ValueError: + return {"error": f"Invalid change_type value: {change_type}"} # Add custom fields if present if custom_fields: - update_data['custom_fields'] = custom_fields + update_data["custom_fields"] = custom_fields + + # Add assets if present + if assets: + update_data["assets"] = assets + + # Handle planning fields + planning_fields = {} + if reason_for_change is not None: + planning_fields["reason_for_change"] = {"description": reason_for_change} + if change_impact is not None: + planning_fields["change_impact"] = {"description": change_impact} + if rollout_plan is not None: + planning_fields["rollout_plan"] = {"description": rollout_plan} + if backout_plan is not None: + planning_fields["backout_plan"] = {"description": backout_plan} - # Add planning fields with proper structure if present if planning_fields: - formatted_planning = {} - for field, value in planning_fields.items(): - if isinstance(value, str): - formatted_planning[field] = {"description": value} - else: - formatted_planning[field] = value - update_data['planning_fields'] = formatted_planning + update_data["planning_fields"] = planning_fields + + if not update_data: + return {"error": "No fields provided for update"} async with httpx.AsyncClient() as client: try: @@ -694,18 +820,19 @@ async def close_change( """Close a change and provide the result explanation. This is a convenience function that updates status to Closed and sets the result explanation.""" - update_data = { - "status": ChangeStatus.CLOSED.value, - "custom_fields": { - "change_result_explanation": change_result_explanation - } + merged_custom_fields = { + "change_result_explanation": change_result_explanation } # Merge additional custom fields if provided if custom_fields: - update_data["custom_fields"].update(custom_fields) + merged_custom_fields.update(custom_fields) - return await update_change(change_id, update_data) + return await update_change( + change_id=change_id, + status=ChangeStatus.CLOSED.value, + custom_fields=merged_custom_fields + ) #DELETE CHANGE @mcp.tool() From 18866bdad56c48b1074b18e65c999ddae1efc7a7 Mon Sep 17 00:00:00 2001 From: Olivier Gintrand Date: Thu, 12 Feb 2026 15:17:51 +0100 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20modularize=20server=20=E2=80=94?= =?UTF-8?q?=20115=20tools=20consolidated=20to=2021?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: VS Code Copilot has a hard limit of 128 tools across all MCP servers. With 115 tools, the Freshservice MCP was triggering VS Code's 'Virtual Tools' mechanism, which groups excess tools into activate_* categories and disables individual tools until activated — breaking the AI agent workflow. Architecture changes: - config.py: centralized enums, constants, AVAILABLE_SCOPES - http_client.py: shared HTTP utilities (api_get/post/put/delete) - discovery.py: dynamic form-field discovery with 2-level TTL cache (in-memory + on-disk JSON in ~/.cache/freshservice_mcp/) - tools/tickets.py: 11 tools → 3 (manage_ticket, manage_ticket_conversation, manage_service_catalog) - tools/changes.py: 33 tools → 5 (manage_change, manage_change_note, manage_change_task, manage_change_time_entry, manage_change_approval) - tools/assets.py: 22 tools → 3 (manage_asset, manage_asset_details, manage_asset_relationship) - tools/agents.py: 10 tools → 2 (manage_agent, manage_agent_group) - tools/requesters.py: 12 tools → 2 (manage_requester, manage_requester_group) - tools/solutions.py: 13 tools → 1 (manage_solution) - tools/products.py: 4 tools → 1 (manage_product) - tools/misc.py: 6 tools → 2 (manage_canned_response, manage_workspace) - discovery tools: 2 (discover_form_fields, clear_field_cache) New features: - --scope CLI flag / FRESHSERVICE_SCOPES env-var to load only specific tool modules (e.g. --scope tickets changes) - Dynamic field discovery replaces hard-coded org-specific parameters - Consistent error handling across all tools - Planning fields properly structured in changes Old server.py preserved as server_legacy.py for reference. --- src/freshservice_mcp/config.py | 110 + src/freshservice_mcp/discovery.py | 173 + src/freshservice_mcp/http_client.py | 70 + src/freshservice_mcp/server.py | 4306 +--------------------- src/freshservice_mcp/server_legacy.py | 4269 +++++++++++++++++++++ src/freshservice_mcp/tools/__init__.py | 38 + src/freshservice_mcp/tools/agents.py | 257 ++ src/freshservice_mcp/tools/assets.py | 439 +++ src/freshservice_mcp/tools/changes.py | 697 ++++ src/freshservice_mcp/tools/misc.py | 105 + src/freshservice_mcp/tools/products.py | 111 + src/freshservice_mcp/tools/requesters.py | 268 ++ src/freshservice_mcp/tools/solutions.py | 240 ++ src/freshservice_mcp/tools/tickets.py | 372 ++ 14 files changed, 7212 insertions(+), 4243 deletions(-) create mode 100644 src/freshservice_mcp/config.py create mode 100644 src/freshservice_mcp/discovery.py create mode 100644 src/freshservice_mcp/http_client.py create mode 100644 src/freshservice_mcp/server_legacy.py create mode 100644 src/freshservice_mcp/tools/__init__.py create mode 100644 src/freshservice_mcp/tools/agents.py create mode 100644 src/freshservice_mcp/tools/assets.py create mode 100644 src/freshservice_mcp/tools/changes.py create mode 100644 src/freshservice_mcp/tools/misc.py create mode 100644 src/freshservice_mcp/tools/products.py create mode 100644 src/freshservice_mcp/tools/requesters.py create mode 100644 src/freshservice_mcp/tools/solutions.py create mode 100644 src/freshservice_mcp/tools/tickets.py diff --git a/src/freshservice_mcp/config.py b/src/freshservice_mcp/config.py new file mode 100644 index 0000000..0025c86 --- /dev/null +++ b/src/freshservice_mcp/config.py @@ -0,0 +1,110 @@ +"""Freshservice MCP — Configuration and constants.""" +import os +import logging +from enum import IntEnum, Enum + +from dotenv import load_dotenv + +load_dotenv() + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("freshservice_mcp") + +# --------------------------------------------------------------------------- +# API credentials +# --------------------------------------------------------------------------- +FRESHSERVICE_DOMAIN = os.getenv("FRESHSERVICE_DOMAIN") +FRESHSERVICE_APIKEY = os.getenv("FRESHSERVICE_APIKEY") + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + +class TicketSource(IntEnum): + EMAIL = 1 + PORTAL = 2 + PHONE = 3 + YAMMER = 6 + CHAT = 7 + AWS_CLOUDWATCH = 7 + PAGERDUTY = 8 + WALK_UP = 9 + SLACK = 10 + WORKPLACE = 12 + EMPLOYEE_ONBOARDING = 13 + ALERTS = 14 + MS_TEAMS = 15 + EMPLOYEE_OFFBOARDING = 18 + +class TicketStatus(IntEnum): + OPEN = 2 + PENDING = 3 + RESOLVED = 4 + CLOSED = 5 + +class TicketPriority(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + URGENT = 4 + +class ChangeStatus(IntEnum): + OPEN = 1 + PLANNING = 2 + AWAITING_APPROVAL = 3 + PENDING_RELEASE = 4 + PENDING_REVIEW = 5 + CLOSED = 6 + +class ChangePriority(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + URGENT = 4 + +class ChangeImpact(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + +class ChangeType(IntEnum): + MINOR = 1 + STANDARD = 2 + MAJOR = 3 + EMERGENCY = 4 + +class ChangeRisk(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + VERY_HIGH = 4 + +class UnassignedForOptions(str, Enum): + THIRTY_MIN = "30m" + ONE_HOUR = "1h" + TWO_HOURS = "2h" + FOUR_HOURS = "4h" + EIGHT_HOURS = "8h" + TWELVE_HOURS = "12h" + ONE_DAY = "1d" + TWO_DAYS = "2d" + THREE_DAYS = "3d" + +# All available scopes for --scope flag +AVAILABLE_SCOPES = [ + "tickets", + "changes", + "assets", + "agents", + "requesters", + "groups", + "solutions", + "products", + "service_catalog", + "canned_responses", + "workspaces", + "discovery", +] diff --git a/src/freshservice_mcp/discovery.py b/src/freshservice_mcp/discovery.py new file mode 100644 index 0000000..3b07ecf --- /dev/null +++ b/src/freshservice_mcp/discovery.py @@ -0,0 +1,173 @@ +"""Freshservice MCP — Form field discovery with TTL cache. + +Provides a tool that dynamically reads the form-field templates from the +Freshservice organisation (ticket fields, change fields, agent fields, +requester fields, asset types …) and caches them locally so we don't +hammer the API on every invocation. +""" +import time +import json +import os +from pathlib import Path +from typing import Any, Dict, Optional + +from .http_client import api_get, handle_error + +# --------------------------------------------------------------------------- +# Cache configuration +# --------------------------------------------------------------------------- +_CACHE_DIR = Path(os.getenv("FRESHSERVICE_CACHE_DIR", Path.home() / ".cache" / "freshservice_mcp")) +_CACHE_TTL = int(os.getenv("FRESHSERVICE_CACHE_TTL", 3600)) # seconds – default 1 h + +# In-memory + on-disk two-level cache +_mem_cache: Dict[str, Dict[str, Any]] = {} + + +def _cache_path(key: str) -> Path: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + return _CACHE_DIR / f"{key}.json" + + +def _read_cache(key: str) -> Optional[Dict[str, Any]]: + """Return cached data if still valid, else None.""" + # 1) in-memory + if key in _mem_cache: + entry = _mem_cache[key] + if time.time() - entry["ts"] < _CACHE_TTL: + return entry["data"] + del _mem_cache[key] + + # 2) on-disk + p = _cache_path(key) + if p.exists(): + try: + raw = json.loads(p.read_text()) + if time.time() - raw["ts"] < _CACHE_TTL: + _mem_cache[key] = raw # promote to memory + return raw["data"] + except (json.JSONDecodeError, KeyError): + pass + return None + + +def _write_cache(key: str, data: Any) -> None: + entry = {"ts": time.time(), "data": data} + _mem_cache[key] = entry + try: + p = _cache_path(key) + p.write_text(json.dumps(entry, default=str)) + except OSError: + pass # non-fatal — memory cache still works + + +def invalidate_cache(key: Optional[str] = None) -> None: + """Clear cache for *key*, or all caches if key is None.""" + global _mem_cache + if key is None: + _mem_cache.clear() + if _CACHE_DIR.exists(): + for f in _CACHE_DIR.glob("*.json"): + f.unlink(missing_ok=True) + else: + _mem_cache.pop(key, None) + _cache_path(key).unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# Entity → API path mapping +# --------------------------------------------------------------------------- +_FIELD_ENDPOINTS: Dict[str, str] = { + "ticket": "ticket_form_fields", + "change": "change_form_fields", + "agent": "agent_fields", + "requester": "requester_fields", +} + + +async def _fetch_fields(entity_type: str) -> Dict[str, Any]: + """Fetch the form-field definitions from Freshservice API.""" + endpoint = _FIELD_ENDPOINTS.get(entity_type) + if not endpoint: + return {"error": f"Unknown entity type '{entity_type}'. Valid types: {list(_FIELD_ENDPOINTS.keys())}"} + + cache_key = f"fields_{entity_type}" + cached = _read_cache(cache_key) + if cached is not None: + return {"source": "cache", "fields": cached} + + try: + resp = await api_get(endpoint) + resp.raise_for_status() + data = resp.json() + _write_cache(cache_key, data) + return {"source": "api", "fields": data} + except Exception as e: + return handle_error(e, f"fetch {entity_type} fields") + + +async def _fetch_asset_types() -> Dict[str, Any]: + """Fetch all asset types (paginated) and cache them.""" + cache_key = "asset_types" + cached = _read_cache(cache_key) + if cached is not None: + return {"source": "cache", "asset_types": cached} + + all_types = [] + page = 1 + try: + while True: + resp = await api_get("asset_types", params={"page": page, "per_page": 100}) + resp.raise_for_status() + data = resp.json() + types = data.get("asset_types", []) + if not types: + break + all_types.extend(types) + page += 1 + except Exception as e: + return handle_error(e, "fetch asset types") + + _write_cache(cache_key, all_types) + return {"source": "api", "asset_types": all_types} + + +# --------------------------------------------------------------------------- +# Tool registration (called from server.py) +# --------------------------------------------------------------------------- +def register_discovery_tools(mcp) -> None: + """Register discovery-related tools on the MCP server.""" + + @mcp.tool() + async def discover_form_fields( + entity_type: str, + force_refresh: bool = False, + ) -> Dict[str, Any]: + """Discover the form-field definitions for an entity type in your Freshservice organisation. + + Returns the list of fields (name, label, type, required, choices…) that + your org has configured. Results are cached locally for 1 hour. + + Args: + entity_type: One of 'ticket', 'change', 'agent', 'requester', 'asset_type' + force_refresh: Set to true to bypass the cache and re-fetch from Freshservice + """ + if force_refresh: + invalidate_cache(f"fields_{entity_type}" if entity_type != "asset_type" else "asset_types") + + if entity_type == "asset_type": + return await _fetch_asset_types() + return await _fetch_fields(entity_type) + + @mcp.tool() + async def clear_field_cache(entity_type: Optional[str] = None) -> Dict[str, Any]: + """Clear the cached form-field definitions. + + Args: + entity_type: Specific entity to clear ('ticket', 'change', …) or omit to clear all. + """ + if entity_type: + key = f"fields_{entity_type}" if entity_type != "asset_type" else "asset_types" + invalidate_cache(key) + return {"success": True, "message": f"Cache cleared for '{entity_type}'"} + invalidate_cache() + return {"success": True, "message": "All field caches cleared"} diff --git a/src/freshservice_mcp/http_client.py b/src/freshservice_mcp/http_client.py new file mode 100644 index 0000000..9108395 --- /dev/null +++ b/src/freshservice_mcp/http_client.py @@ -0,0 +1,70 @@ +"""Freshservice MCP — Shared HTTP client utilities.""" +import re +import base64 +import httpx +from typing import Optional, Dict, Any + +from .config import FRESHSERVICE_DOMAIN, FRESHSERVICE_APIKEY + + +def get_auth_headers() -> Dict[str, str]: + """Return Basic-auth + JSON content-type headers.""" + return { + "Authorization": f"Basic {base64.b64encode(f'{FRESHSERVICE_APIKEY}:X'.encode()).decode()}", + "Content-Type": "application/json", + } + + +def parse_link_header(link_header: str) -> Dict[str, Optional[int]]: + """Parse the HTTP Link header to extract pagination page numbers.""" + pagination: Dict[str, Optional[int]] = {"next": None, "prev": None} + if not link_header: + return pagination + for link in link_header.split(","): + match = re.search(r'<(.+?)>;\s*rel="(.+?)"', link) + if match: + url, rel = match.groups() + page_match = re.search(r"page=(\d+)", url) + if page_match: + pagination[rel] = int(page_match.group(1)) + return pagination + + +def api_url(path: str) -> str: + """Build a full Freshservice API v2 URL.""" + return f"https://{FRESHSERVICE_DOMAIN}/api/v2/{path.lstrip('/')}" + + +async def api_get(path: str, params: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Perform an authenticated GET request.""" + async with httpx.AsyncClient() as client: + return await client.get(api_url(path), headers=get_auth_headers(), params=params) + + +async def api_post(path: str, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Perform an authenticated POST request.""" + async with httpx.AsyncClient() as client: + return await client.post(api_url(path), headers=get_auth_headers(), json=json) + + +async def api_put(path: str, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Perform an authenticated PUT request.""" + async with httpx.AsyncClient() as client: + return await client.put(api_url(path), headers=get_auth_headers(), json=json) + + +async def api_delete(path: str) -> httpx.Response: + """Perform an authenticated DELETE request.""" + async with httpx.AsyncClient() as client: + return await client.delete(api_url(path), headers=get_auth_headers()) + + +def handle_error(e: Exception, action: str = "request") -> Dict[str, Any]: + """Standardised error response builder.""" + if isinstance(e, httpx.HTTPStatusError): + try: + details = e.response.json() + except Exception: + details = e.response.text + return {"success": False, "error": f"Failed to {action}: {e}", "details": details} + return {"success": False, "error": f"Unexpected error during {action}: {e}"} diff --git a/src/freshservice_mcp/server.py b/src/freshservice_mcp/server.py index 7813ded..3d12281 100644 --- a/src/freshservice_mcp/server.py +++ b/src/freshservice_mcp/server.py @@ -1,4269 +1,89 @@ -import os -import re -import httpx -import logging -import base64 -import json -import urllib.parse -from typing import Optional, Dict, Union, Any, List -from mcp.server.fastmcp import FastMCP -from enum import IntEnum, Enum -from pydantic import BaseModel, Field - - -from dotenv import load_dotenv -load_dotenv() - - -# Set up logging -logging.basicConfig(level=logging.INFO) - - -# Create MCP INSTANCE -mcp = FastMCP("freshservice_mcp") - - -# API CREDENTIALS -FRESHSERVICE_DOMAIN = os.getenv("FRESHSERVICE_DOMAIN") -FRESHSERVICE_APIKEY = os.getenv("FRESHSERVICE_APIKEY") - - -class TicketSource(IntEnum): - PHONE = 3 - EMAIL = 1 - PORTAL = 2 - CHAT = 7 - YAMMER = 6 - PAGERDUTY = 8 - AWS_CLOUDWATCH = 7 - WALK_UP = 9 - SLACK=10 - WORKPLACE = 12 - EMPLOYEE_ONBOARDING = 13 - ALERTS = 14 - MS_TEAMS = 15 - EMPLOYEE_OFFBOARDING = 18 - -class TicketStatus(IntEnum): - OPEN = 2 - PENDING = 3 - RESOLVED = 4 - CLOSED = 5 - -class TicketPriority(IntEnum): - LOW = 1 - MEDIUM = 2 - HIGH = 3 - URGENT = 4 - -class ChangeStatus(IntEnum): - OPEN = 1 - PLANNING = 2 - AWAITING_APPROVAL = 3 - PENDING_RELEASE = 4 - PENDING_REVIEW = 5 - CLOSED = 6 - -class ChangePriority(IntEnum): - LOW = 1 - MEDIUM = 2 - HIGH = 3 - URGENT = 4 - -class ChangeImpact(IntEnum): - LOW = 1 - MEDIUM = 2 - HIGH = 3 - -class ChangeType(IntEnum): - MINOR = 1 - STANDARD = 2 - MAJOR = 3 - EMERGENCY = 4 - -class ChangeRisk(IntEnum): - LOW = 1 - MEDIUM = 2 - HIGH = 3 - VERY_HIGH = 4 - -class UnassignedForOptions(str, Enum): - THIRTY_MIN = "30m" - ONE_HOUR = "1h" - TWO_HOURS = "2h" - FOUR_HOURS = "4h" - EIGHT_HOURS = "8h" - TWELVE_HOURS = "12h" - ONE_DAY = "1d" - TWO_DAYS = "2d" - THREE_DAYS = "3d" - -class FilterRequestersSchema(BaseModel): - query: str = Field(..., description="Main query string to filter requesters (e.g., first_name:'Vijay')") - custom_fields: Optional[Dict[str, str]] = Field(default=None, description="Custom fields to filter (key-value pairs)") - include_agents: Optional[bool] = Field(default=False, description="Include agents in the response") - page: Optional[int] = Field(default=1, description="Page number for pagination (default is 1)") - -class AgentInput(BaseModel): - first_name: str = Field(..., description="First name of the agent") - last_name: Optional[str] = Field(None, description="Last name of the agent") - occasional: Optional[bool] = Field(False, description="True if the agent is an occasional agent") - job_title: Optional[str] = Field(None, description="Job title of the agent") - email: Optional[str]= Field(..., description="Email address of the agent") - work_phone_number: Optional[int] = Field(None, description="Work phone number of the agent") - mobile_phone_number: Optional[int] = Field(None, description="Mobile phone number of the agent") - -class GroupCreate(BaseModel): - name: str = Field(..., description="Name of the group") - description: Optional[str] = Field(None, description="Description of the group") - agent_ids: Optional[List[int]] = Field( - default=None, - description="Array of agent user ids" - ) - auto_ticket_assign: Optional[bool] = Field( - default=False, - description="Whether tickets are automatically assigned (true or false)" - ) - escalate_to: Optional[int] = Field( - None, - description="User ID to whom escalation email is sent if ticket is unassigned" - ) - unassigned_for: Optional[UnassignedForOptions] = Field( - default=UnassignedForOptions.THIRTY_MIN, - description="Time after which escalation email will be sent" - ) - -def parse_link_header(link_header: str) -> Dict[str, Optional[int]]: - """Parse the Link header to extract pagination information. - - Args: - link_header: The Link header string from the response - - Returns: - Dictionary containing next and prev page numbers - """ - pagination = { - "next": None, - "prev": None - } - - if not link_header: - return pagination - - - links = link_header.split(',') - - for link in links: - match = re.search(r'<(.+?)>;\s*rel="(.+?)"', link) - if match: - url, rel = match.groups() - page_match = re.search(r'page=(\d+)', url) - if page_match: - page_num = int(page_match.group(1)) - pagination[rel] = page_num - - return pagination - -#GET TICKET FIELDS -@mcp.tool() -async def get_ticket_fields() -> Dict[str, Any]: - """Get ticket fields from Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/ticket_form_fields" - headers = get_auth_headers() - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - return response.json() - -#GET TICKETS -@mcp.tool() -async def get_tickets(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: - """Get tickets from Freshservice with pagination support.""" - - if page < 1: - return {"error": "Page number must be greater than 0"} - - if per_page < 1 or per_page > 100: - return {"error": "Page size must be between 1 and 100"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets" - - params = { - "page": page, - "per_page": per_page - } - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - link_header = response.headers.get('Link', '') - pagination_info = parse_link_header(link_header) - - tickets = response.json() - - return { - "tickets": tickets, - "pagination": { - "current_page": page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - "per_page": per_page - } - } - - except httpx.HTTPStatusError as e: - return {"error": f"Failed to fetch tickets: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} +"""Freshservice MCP Server — slim entry-point. -#CREATE TICKET -@mcp.tool() -async def create_ticket( - subject: str, - description: str, - source: Union[int, str], - priority: Union[int, str], - status: Union[int, str], - email: Optional[str] = None, - requester_id: Optional[int] = None, - custom_fields: Optional[Dict[str, Any]] = None -) -> str: - """Create a ticket in Freshservice.""" - - if not email and not requester_id: - return "Error: Either email or requester_id must be provided" +All tools live in ``freshservice_mcp.tools.*`` sub-modules. +This file creates the FastMCP instance, loads the requested scopes, +and starts the server. - try: - source_val = int(source) - priority_val = int(priority) - status_val = int(status) - except ValueError: - return "Error: Invalid value for source, priority, or status" +Usage: + freshservice-mcp # loads all scopes + freshservice-mcp --scope tickets changes # loads only tickets & changes + FRESHSERVICE_SCOPES=tickets,changes freshservice-mcp # env-var alternative +""" - if (source_val not in [e.value for e in TicketSource] or - priority_val not in [e.value for e in TicketPriority] or - status_val not in [e.value for e in TicketStatus]): - return "Error: Invalid value for source, priority, or status" - - data = { - "subject": subject, - "description": description, - "source": source_val, - "priority": priority_val, - "status": status_val - } - - if email: - data["email"] = email - if requester_id: - data["requester_id"] = requester_id - - if custom_fields: - data["custom_fields"] = custom_fields - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=data) - response.raise_for_status() - - response_data = response.json() - return f"Ticket created successfully: {response_data}" - - except httpx.HTTPStatusError as e: - if e.response.status_code == 400: - error_data = e.response.json() - if "errors" in error_data: - return f"Validation Error: {error_data['errors']}" - return f"Error: Failed to create ticket - {str(e)}" - except Exception as e: - return f"Error: An unexpected error occurred - {str(e)}" - -#UPDATE TICKET -@mcp.tool() -async def update_ticket(ticket_id: int, ticket_fields: Dict[str, Any]) -> Dict[str, Any]: - """Update a ticket in Freshservice.""" - if not ticket_fields: - return {"error": "No fields provided for update"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}" - headers = get_auth_headers() - - custom_fields = ticket_fields.pop('custom_fields', {}) - - update_data = {} - - for field, value in ticket_fields.items(): - update_data[field] = value - - if custom_fields: - update_data['custom_fields'] = custom_fields - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=update_data) - response.raise_for_status() - - return { - "success": True, - "message": "Ticket updated successfully", - "ticket": response.json() - } - - except httpx.HTTPStatusError as e: - error_message = f"Failed to update ticket: {str(e)}" - try: - error_details = e.response.json() - if "errors" in error_details: - error_message = f"Validation errors: {error_details['errors']}" - except Exception: - pass - return { - "success": False, - "error": error_message - } - except Exception as e: - return { - "success": False, - "error": f"An unexpected error occurred: {str(e)}" - } - -#FILTER TICKET -@mcp.tool() -async def filter_tickets(query: str, page: int = 1, workspace_id: Optional[int] = None) -> Dict[str, Any]: - """Filter the tickets in Freshservice. - - Args: - query: Filter query string (e.g., "status:2 AND priority:1") - Note: Some Freshservice endpoints may require queries to be wrapped in double quotes. - If you get 500 errors, try wrapping your query in double quotes: "your_query_here" - page: Page number (default: 1) - workspace_id: Optional workspace ID filter - """ - # Freshservice API requires the query to be wrapped in double quotes - encoded_query = urllib.parse.quote(f'"{query}"') - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/filter?query={encoded_query}&page={page}" - - if workspace_id is not None: - url += f"&workspace_id={workspace_id}" - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#DELETE TICKET. -@mcp.tool() -async def delete_ticket(ticket_id: int) -> str: - """Delete a ticket in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.delete(url, headers=headers) - - if response.status_code == 204: - # No content returned on successful deletion - return "Ticket deleted successfully" - elif response.status_code == 404: - return "Error: Ticket not found" - else: - try: - response_data = response.json() - return f"Error: {response_data.get('error', 'Failed to delete ticket')}" - except ValueError: - return "Error: Unexpected response format" - -#GET TICKET BY ID -@mcp.tool() -async def get_ticket_by_id(ticket_id:int) -> Dict[str, Any]: - """Get a ticket in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url,headers=headers) - return response.json() - -#GET ALL CHANGES -@mcp.tool() -async def get_changes( - page: Optional[int] = 1, - per_page: Optional[int] = 30, - query: Optional[str] = None, - view: Optional[str] = None, - sort: Optional[str] = None, - order_by: Optional[str] = None, - updated_since: Optional[str] = None, - workspace_id: Optional[int] = None -) -> Dict[str, Any]: - """Get all changes from Freshservice with pagination and filtering support. - - Args: - page: Page number (default: 1) - per_page: Number of items per page (1-100, default: 30) - query: Filter query string (e.g., "priority:4 OR priority:3", "status:2 AND priority:1") - **IMPORTANT**: Query must be wrapped in double quotes for filtering to work! - Examples: "status:3", "approval_status:1 AND status:<6", "planned_start_date:>'2025-07-14'" - view: Accepts the name or ID of views (e.g., 'my_open', 'unassigned') - sort: Field to sort by (e.g., 'priority', 'created_at') - order_by: Sort order ('asc' or 'desc', default: 'desc') - updated_since: Changes updated since date (ISO format: '2024-10-19T02:00:00Z') - workspace_id: Filter by workspace ID (0 for all workspaces) - - Query examples: - - "priority:4 OR priority:3" - Urgent and High priority changes - - "priority:>3 AND group_id:11 AND status:1" - High priority open changes for group 11 - - "status:2" - Open changes - - "status:<6" - Not closed changes (statuses 1-5) - - "approval_status:1" - Approved changes - - "planned_end_date:<'2025-01-14'" - Changes with end date before specified date - - Note: Query and view parameters cannot be used together - """ - - if page < 1: - return {"error": "Page number must be greater than 0"} - - if per_page < 1 or per_page > 100: - return {"error": "Page size must be between 1 and 100"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes" - - params = { - "page": page, - "per_page": per_page - } - - if query: - params["query"] = query - if view: - params["view"] = view - if sort: - params["sort"] = sort - if order_by: - params["order_by"] = order_by - if updated_since: - params["updated_since"] = updated_since - if workspace_id is not None: - params["workspace_id"] = workspace_id - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - link_header = response.headers.get('Link', '') - pagination_info = parse_link_header(link_header) - - changes = response.json() - - return { - "changes": changes, - "pagination": { - "current_page": page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - "per_page": per_page - } - } - - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - -#GET CHANGE BY ID -@mcp.tool() -async def get_change_by_id(change_id: int) -> Dict[str, Any]: - """Get a specific change by ID in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - return {"error": f"Failed to fetch change: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - -#CREATE CHANGE -@mcp.tool() -async def create_change( - requester_id: int, - subject: str, - description: str, - priority: Union[int, str], - impact: Union[int, str], - status: Union[int, str], - risk: Union[int, str], - change_type: Union[int, str], - group_id: Optional[int] = None, - agent_id: Optional[int] = None, - department_id: Optional[int] = None, - category: Optional[str] = None, - sub_category: Optional[str] = None, - item_category: Optional[str] = None, - planned_start_date: Optional[str] = None, - planned_end_date: Optional[str] = None, - reason_for_change: Optional[str] = None, - change_impact: Optional[str] = None, - rollout_plan: Optional[str] = None, - backout_plan: Optional[str] = None, - custom_fields: Optional[Dict[str, Any]] = None, - assets: Optional[List[Dict[str, Any]]] = None -) -> Dict[str, Any]: - """Create a new change in Freshservice. - - Args: - requester_id: Unique identifier of the initiator of the change (MANDATORY) - subject: Change subject (MANDATORY) - description: HTML content of the change (MANDATORY) - priority: Priority (1=Low, 2=Medium, 3=High, 4=Urgent) (MANDATORY) - impact: Impact (1=Low, 2=Medium, 3=High) (MANDATORY) - status: Status (1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, 5=Pending Review, 6=Closed) (MANDATORY) - risk: Risk (1=Low, 2=Medium, 3=High, 4=Very High) (MANDATORY) - change_type: Type (1=Minor, 2=Standard, 3=Major, 4=Emergency) (MANDATORY) - group_id: Agent group ID - agent_id: Agent ID - department_id: Department ID - category: Category of the change - sub_category: Sub-category of the change - item_category: Item category of the change - planned_start_date: Planned start date (ISO format) - planned_end_date: Planned end date (ISO format) - reason_for_change: Planning field - Reason for change (text/HTML) - change_impact: Planning field - Impact analysis (text/HTML) - rollout_plan: Planning field - Rollout plan (text/HTML) - backout_plan: Planning field - Backout plan (text/HTML) - custom_fields: Custom fields key-value pairs - assets: List of assets to associate, e.g. [{"display_id": 1}, {"display_id": 2}] - """ - - try: - priority_val = int(priority) - impact_val = int(impact) - status_val = int(status) - risk_val = int(risk) - change_type_val = int(change_type) - except ValueError: - return {"error": "Invalid value for priority, impact, status, risk, or change_type"} +import argparse +import logging +import os +import sys - if (priority_val not in [e.value for e in ChangePriority] or - impact_val not in [e.value for e in ChangeImpact] or - status_val not in [e.value for e in ChangeStatus] or - risk_val not in [e.value for e in ChangeRisk] or - change_type_val not in [e.value for e in ChangeType]): - return {"error": "Invalid value for priority, impact, status, risk, or change_type"} +from mcp.server.fastmcp import FastMCP - data = { - "requester_id": requester_id, - "subject": subject, - "description": description, - "priority": priority_val, - "impact": impact_val, - "status": status_val, - "risk": risk_val, - "change_type": change_type_val - } +from .discovery import register_discovery_tools +from .tools import SCOPE_REGISTRY - if group_id: - data["group_id"] = group_id - if agent_id: - data["agent_id"] = agent_id - if department_id: - data["department_id"] = department_id - if category: - data["category"] = category - if sub_category: - data["sub_category"] = sub_category - if item_category: - data["item_category"] = item_category - if planned_start_date: - data["planned_start_date"] = planned_start_date - if planned_end_date: - data["planned_end_date"] = planned_end_date - # Handle planning fields - planning_fields = {} - if reason_for_change: - planning_fields["reason_for_change"] = { - "description": reason_for_change - } - if change_impact: - planning_fields["change_impact"] = { - "description": change_impact - } - if rollout_plan: - planning_fields["rollout_plan"] = { - "description": rollout_plan - } - if backout_plan: - planning_fields["backout_plan"] = { - "description": backout_plan - } - - if planning_fields: - data["planning_fields"] = planning_fields +# ── logging ──────────────────────────────────────────────────────────────── +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) - if custom_fields: - data["custom_fields"] = custom_fields - if assets: - data["assets"] = assets +# ── MCP instance ─────────────────────────────────────────────────────────── +mcp = FastMCP("freshservice_mcp") - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes" - headers = get_auth_headers() - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - if e.response.status_code == 400: - error_data = e.response.json() - if "errors" in error_data: - return {"error": f"Validation Error: {error_data['errors']}"} - return {"error": f"Failed to create change - {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred - {str(e)}"} +# ── scope resolution ────────────────────────────────────────────────────── +def _resolve_scopes(cli_scopes: list[str] | None) -> list[str]: + """Return the list of scope names to load. -#UPDATE CHANGE -@mcp.tool() -async def update_change( - change_id: int, - subject: Optional[str] = None, - description: Optional[str] = None, - priority: Optional[Union[int, str]] = None, - impact: Optional[Union[int, str]] = None, - status: Optional[Union[int, str]] = None, - risk: Optional[Union[int, str]] = None, - change_type: Optional[Union[int, str]] = None, - group_id: Optional[int] = None, - agent_id: Optional[int] = None, - department_id: Optional[int] = None, - category: Optional[str] = None, - sub_category: Optional[str] = None, - item_category: Optional[str] = None, - planned_start_date: Optional[str] = None, - planned_end_date: Optional[str] = None, - reason_for_change: Optional[str] = None, - change_impact: Optional[str] = None, - rollout_plan: Optional[str] = None, - backout_plan: Optional[str] = None, - custom_fields: Optional[Dict[str, Any]] = None, - assets: Optional[List[Dict[str, Any]]] = None -) -> Dict[str, Any]: - """Update an existing change in Freshservice. - - Args: - change_id: The ID of the change to update - subject: Change subject - description: HTML content of the change - priority: Priority (1=Low, 2=Medium, 3=High, 4=Urgent) - impact: Impact (1=Low, 2=Medium, 3=High) - status: Status (1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, 5=Pending Review, 6=Closed) - risk: Risk (1=Low, 2=Medium, 3=High, 4=Very High) - change_type: Type (1=Minor, 2=Standard, 3=Major, 4=Emergency) - group_id: Agent group ID - agent_id: Agent ID - department_id: Department ID - category: Category of the change - sub_category: Sub-category of the change - item_category: Item category of the change - planned_start_date: Planned start date (ISO format) - planned_end_date: Planned end date (ISO format) - reason_for_change: Planning field - Reason for change (text/HTML) - change_impact: Planning field - Impact analysis (text/HTML) - rollout_plan: Planning field - Rollout plan (text/HTML) - backout_plan: Planning field - Backout plan (text/HTML) - custom_fields: Custom fields key-value pairs - assets: List of assets to associate, e.g. [{"display_id": 1}, {"display_id": 2}] + Priority: CLI args > FRESHSERVICE_SCOPES env-var > all. """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" - headers = get_auth_headers() - - update_data = {} - - # Add regular fields if provided - if subject is not None: - update_data["subject"] = subject - if description is not None: - update_data["description"] = description - if group_id is not None: - update_data["group_id"] = group_id - if agent_id is not None: - update_data["agent_id"] = agent_id - if department_id is not None: - update_data["department_id"] = department_id - if category is not None: - update_data["category"] = category - if sub_category is not None: - update_data["sub_category"] = sub_category - if item_category is not None: - update_data["item_category"] = item_category - if planned_start_date is not None: - update_data["planned_start_date"] = planned_start_date - if planned_end_date is not None: - update_data["planned_end_date"] = planned_end_date - - # Handle enum fields with validation - if priority is not None: - try: - update_data["priority"] = int(priority) - except ValueError: - return {"error": f"Invalid priority value: {priority}"} - if impact is not None: - try: - update_data["impact"] = int(impact) - except ValueError: - return {"error": f"Invalid impact value: {impact}"} - if status is not None: - try: - update_data["status"] = int(status) - except ValueError: - return {"error": f"Invalid status value: {status}"} - if risk is not None: - try: - update_data["risk"] = int(risk) - except ValueError: - return {"error": f"Invalid risk value: {risk}"} - if change_type is not None: - try: - update_data["change_type"] = int(change_type) - except ValueError: - return {"error": f"Invalid change_type value: {change_type}"} - - # Add custom fields if present - if custom_fields: - update_data["custom_fields"] = custom_fields - - # Add assets if present - if assets: - update_data["assets"] = assets - - # Handle planning fields - planning_fields = {} - if reason_for_change is not None: - planning_fields["reason_for_change"] = {"description": reason_for_change} - if change_impact is not None: - planning_fields["change_impact"] = {"description": change_impact} - if rollout_plan is not None: - planning_fields["rollout_plan"] = {"description": rollout_plan} - if backout_plan is not None: - planning_fields["backout_plan"] = {"description": backout_plan} - - if planning_fields: - update_data["planning_fields"] = planning_fields + if cli_scopes: + scopes = cli_scopes + else: + env = os.getenv("FRESHSERVICE_SCOPES", "").strip() + scopes = [s.strip() for s in env.split(",") if s.strip()] if env else list(SCOPE_REGISTRY) - if not update_data: - return {"error": "No fields provided for update"} + invalid = [s for s in scopes if s not in SCOPE_REGISTRY] + if invalid: + log.error( + "Unknown scope(s): %s — valid scopes: %s", + ", ".join(invalid), + ", ".join(SCOPE_REGISTRY), + ) + sys.exit(1) + return scopes - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=update_data) - response.raise_for_status() - - return { - "success": True, - "message": "Change updated successfully", - "change": response.json() - } - - except httpx.HTTPStatusError as e: - error_message = f"Failed to update change: {str(e)}" - try: - error_details = e.response.json() - if "errors" in error_details: - error_message = f"Validation errors: {error_details['errors']}" - except Exception: - pass - return { - "success": False, - "error": error_message - } - except Exception as e: - return { - "success": False, - "error": f"An unexpected error occurred: {str(e)}" - } -#CLOSE CHANGE WITH RESULT -@mcp.tool() -async def close_change( - change_id: int, - change_result_explanation: str, - custom_fields: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: - """Close a change and provide the result explanation. - This is a convenience function that updates status to Closed and sets the result explanation.""" - - merged_custom_fields = { - "change_result_explanation": change_result_explanation - } - - # Merge additional custom fields if provided - if custom_fields: - merged_custom_fields.update(custom_fields) - - return await update_change( - change_id=change_id, - status=ChangeStatus.CLOSED.value, - custom_fields=merged_custom_fields +# ── main ─────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser( + description="Freshservice MCP Server", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=f"Available scopes: {', '.join(SCOPE_REGISTRY)}", ) - -#DELETE CHANGE -@mcp.tool() -async def delete_change(change_id: int) -> str: - """Delete a change in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.delete(url, headers=headers) - - if response.status_code == 204: - return "Change deleted successfully" - elif response.status_code == 404: - return "Error: Change not found" - else: - try: - response_data = response.json() - return f"Error: {response_data.get('error', 'Failed to delete change')}" - except ValueError: - return "Error: Unexpected response format" - - -# FILTER CHANGES -@mcp.tool() -async def filter_changes( - query: str, - page: int = 1, - per_page: int = 30, - sort: Optional[str] = None, - order_by: Optional[str] = None, - workspace_id: Optional[int] = None -) -> Dict[str, Any]: - """Filter changes in Freshservice based on a query. - - Args: - query: Filter query string (e.g., "status:2 AND priority:1" or "approval_status:1 AND planned_end_date:<'2025-01-14' AND status:<6") - **CRITICAL**: Query must be wrapped in double quotes for filtering to work! - Without quotes: status:3 → 500 Internal Server Error - With quotes: "status:3" → Works perfectly - page: Page number (default: 1) - per_page: Number of items per page (1-100, default: 30) - sort: Field to sort by - order_by: Sort order ('asc' or 'desc') - workspace_id: Optional workspace ID filter - - Common query examples: - - "status:2" - Open changes - - "status:<6" - Not closed changes (statuses 1-5) - - "approval_status:1" - Approved changes - - "planned_end_date:<'2025-01-14'" - Changes with end date before specified date - - "priority:1 AND status:2" - High priority open changes - - "approval_status:1 AND status:3" - Approved changes awaiting implementation - """ - # Use the main get_changes function with query parameter - # This is the correct approach since /api/v2/changes/filter doesn't exist - return await get_changes( - page=page, - per_page=per_page, - query=query, - sort=sort, - order_by=order_by, - workspace_id=workspace_id + parser.add_argument( + "--scope", + nargs="*", + metavar="SCOPE", + help="Load only these tool scopes (default: all). " + "Can also be set via FRESHSERVICE_SCOPES env-var (comma-separated).", ) -#GET CHANGE TASKS -@mcp.tool() -async def get_change_tasks(change_id: int) -> Dict[str, Any]: - """Get all tasks associated with a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - return {"error": f"Failed to fetch change tasks: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - -#CREATE CHANGE NOTE -@mcp.tool() -async def create_change_note(change_id: int, body: str) -> Dict[str, Any]: - """Create a note for a change in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes" - headers = get_auth_headers() - data = { - "body": body - } - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - return {"error": f"Failed to create change note: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - -# CHANGES APPROVAL ENDPOINTS - -#CREATE CHANGE APPROVAL GROUP -@mcp.tool() -async def create_change_approval_group( - change_id: int, - name: str, - approver_ids: List[int], - approval_type: str = "everyone" -) -> Dict[str, Any]: - """Create an approval group for a change. - - Args: - change_id: The ID of the change - name: Name of the approval group - approver_ids: List of agent IDs who can approve - approval_type: 'everyone' or 'any' (default: 'everyone') - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_groups" - headers = get_auth_headers() - data = { - "name": name, - "approver_ids": approver_ids, - "approval_type": approval_type - } - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#UPDATE CHANGE APPROVAL GROUP -@mcp.tool() -async def update_change_approval_group( - change_id: int, - group_id: int, - name: Optional[str] = None, - approver_ids: Optional[List[int]] = None, - approval_type: Optional[str] = None -) -> Dict[str, Any]: - """Update a change approval group.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_groups/{group_id}" - headers = get_auth_headers() - - data = {} - if name is not None: - data["name"] = name - if approver_ids is not None: - data["approver_ids"] = approver_ids - if approval_type is not None: - data["approval_type"] = approval_type - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#CANCEL CHANGE APPROVAL GROUP -@mcp.tool() -async def cancel_change_approval_group(change_id: int, group_id: int) -> Dict[str, Any]: - """Cancel a change approval group.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_groups/{group_id}/cancel" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers) - response.raise_for_status() - return {"success": True, "message": "Approval group cancelled successfully"} - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#UPDATE APPROVAL CHAIN RULE FOR CHANGE -@mcp.tool() -async def update_approval_chain_rule_change( - change_id: int, - approval_chain_type: str = "parallel" -) -> Dict[str, Any]: - """Update approval chain rule for a change. - - Args: - change_id: The ID of the change - approval_chain_type: Type of approval chain ('parallel' or 'sequential') - """ - if approval_chain_type not in ["parallel", "sequential"]: - return {"error": "approval_chain_type must be 'parallel' or 'sequential'"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_chain" - headers = get_auth_headers() - data = {"approval_chain_type": approval_chain_type} - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#LIST CHANGE APPROVAL GROUPS -@mcp.tool() -async def list_change_approval_groups(change_id: int) -> Dict[str, Any]: - """List all approval groups within a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_groups" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#VIEW CHANGE APPROVAL -@mcp.tool() -async def view_change_approval(change_id: int, approval_id: int) -> Dict[str, Any]: - """View a specific change approval.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approvals/{approval_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#LIST CHANGE APPROVALS -@mcp.tool() -async def list_change_approvals(change_id: int) -> Dict[str, Any]: - """List all change approvals.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approvals" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#SEND CHANGE APPROVAL REMINDER -@mcp.tool() -async def send_change_approval_reminder(change_id: int, approval_id: int) -> Dict[str, Any]: - """Send reminder for a change approval.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approvals/{approval_id}/resend_approval" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers) - response.raise_for_status() - return {"success": True, "message": "Reminder sent successfully"} - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#CANCEL CHANGE APPROVAL -@mcp.tool() -async def cancel_change_approval(change_id: int, approval_id: int) -> Dict[str, Any]: - """Cancel a change approval.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approvals/{approval_id}/cancel" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers) - response.raise_for_status() - return {"success": True, "message": "Approval cancelled successfully"} - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -# CHANGES NOTES ENDPOINTS - -#VIEW CHANGE NOTE -@mcp.tool() -async def view_change_note(change_id: int, note_id: int) -> Dict[str, Any]: - """View a specific note for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes/{note_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#LIST CHANGE NOTES -@mcp.tool() -async def list_change_notes(change_id: int) -> Dict[str, Any]: - """List all notes for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#UPDATE CHANGE NOTE -@mcp.tool() -async def update_change_note(change_id: int, note_id: int, body: str) -> Dict[str, Any]: - """Update a note for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes/{note_id}" - headers = get_auth_headers() - data = {"body": body} - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#DELETE CHANGE NOTE -@mcp.tool() -async def delete_change_note(change_id: int, note_id: int) -> Dict[str, Any]: - """Delete a note for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes/{note_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.delete(url, headers=headers) - if response.status_code == 204: - return {"success": True, "message": "Note deleted successfully"} - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -# CHANGES TASKS ENDPOINTS - -#CREATE CHANGE TASK -@mcp.tool() -async def create_change_task( - change_id: int, - title: str, - description: str, - status: int = 1, - priority: int = 1, - assigned_to_id: Optional[int] = None, - group_id: Optional[int] = None, - due_date: Optional[str] = None -) -> Dict[str, Any]: - """Create a task for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks" - headers = get_auth_headers() - - data = { - "title": title, - "description": description, - "status": status, - "priority": priority - } - - if assigned_to_id: - data["assigned_to_id"] = assigned_to_id - if group_id: - data["group_id"] = group_id - if due_date: - data["due_date"] = due_date - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#VIEW CHANGE TASK -@mcp.tool() -async def view_change_task(change_id: int, task_id: int) -> Dict[str, Any]: - """View a specific task for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks/{task_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#UPDATE CHANGE TASK -@mcp.tool() -async def update_change_task( - change_id: int, - task_id: int, - task_fields: Dict[str, Any] -) -> Dict[str, Any]: - """Update a task for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks/{task_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=task_fields) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#DELETE CHANGE TASK -@mcp.tool() -async def delete_change_task(change_id: int, task_id: int) -> Dict[str, Any]: - """Delete a task for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks/{task_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.delete(url, headers=headers) - if response.status_code == 204: - return {"success": True, "message": "Task deleted successfully"} - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -# CHANGES TIME ENTRIES ENDPOINTS - -#CREATE CHANGE TIME ENTRY -@mcp.tool() -async def create_change_time_entry( - change_id: int, - time_spent: str, - note: str, - agent_id: int, - executed_at: Optional[str] = None -) -> Dict[str, Any]: - """Create a time entry for a change. - - Args: - change_id: The ID of the change - time_spent: Time spent in format "hh:mm" (e.g., "02:30") - note: Description of the work done - agent_id: ID of the agent who performed the work - executed_at: When the work was done (ISO format) - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries" - headers = get_auth_headers() - - data = { - "time_spent": time_spent, - "note": note, - "agent_id": agent_id - } - - if executed_at: - data["executed_at"] = executed_at - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#VIEW CHANGE TIME ENTRY -@mcp.tool() -async def view_change_time_entry(change_id: int, time_entry_id: int) -> Dict[str, Any]: - """View a specific time entry for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries/{time_entry_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#LIST CHANGE TIME ENTRIES -@mcp.tool() -async def list_change_time_entries(change_id: int) -> Dict[str, Any]: - """List all time entries for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#UPDATE CHANGE TIME ENTRY -@mcp.tool() -async def update_change_time_entry( - change_id: int, - time_entry_id: int, - time_spent: Optional[str] = None, - note: Optional[str] = None -) -> Dict[str, Any]: - """Update a time entry for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries/{time_entry_id}" - headers = get_auth_headers() - - data = {} - if time_spent is not None: - data["time_spent"] = time_spent - if note is not None: - data["note"] = note - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#DELETE CHANGE TIME ENTRY -@mcp.tool() -async def delete_change_time_entry(change_id: int, time_entry_id: int) -> Dict[str, Any]: - """Delete a time entry for a change.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries/{time_entry_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.delete(url, headers=headers) - if response.status_code == 204: - return {"success": True, "message": "Time entry deleted successfully"} - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -# OTHER CHANGES ENDPOINTS - -#MOVE CHANGE -@mcp.tool() -async def move_change(change_id: int, workspace_id: int) -> Dict[str, Any]: - """Move a change to another workspace.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/move_workspace" - headers = get_auth_headers() - data = {"workspace_id": workspace_id} - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#LIST CHANGE FIELDS -@mcp.tool() -async def list_change_fields() -> Dict[str, Any]: - """List all change fields.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/change_form_fields" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - -#GET SERVICE ITEMS -@mcp.tool() -async def list_service_items(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: - """Get list of service items from Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/service_catalog/items" - - if page < 1: - return {"error": "Page number must be greater than 0"} - if per_page < 1 or per_page > 100: - return {"error": "Page size must be between 1 and 100"} - - headers = get_auth_headers() - all_items: List[Dict[str, Any]] = [] - current_page = page - - async with httpx.AsyncClient() as client: - while True: - params = { - "page": current_page, - "per_page": per_page - } - - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - data = response.json() - all_items.append(data) # Store the entire response for each page - - link_header = response.headers.get("Link", "") - pagination_info = parse_link_header(link_header) - - if not pagination_info.get("next"): - break - - current_page = pagination_info["next"] - - except httpx.HTTPStatusError as e: - return {"error": f"HTTP error occurred: {str(e)}"} - except Exception as e: - return {"error": f"Unexpected error: {str(e)}"} - - return { - "success": True, - "items": all_items, - "pagination": { - "starting_page": page, - "per_page": per_page, - "last_fetched_page": current_page - } - } - -#GET REQUESTED ITEMS -@mcp.tool() -async def get_requested_items(ticket_id: int) -> dict: - """Fetch requested items for a specific ticket if the ticket is a service request.""" - - async def get_ticket(ticket_id: int) -> dict: - """Fetch ticket details by ticket ID to check the ticket type.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - ticket_data = response.json() - - # Check if the ticket type is a service request - if ticket_data.get("ticket", {}).get("type") != "Service Request": - return {"success": False, "error": "Requested items can only be fetched for service requests"} - - # If ticket is a service request, proceed to fetch the requested items - return {"success": True, "ticket_type": "Service Request"} - - except httpx.HTTPStatusError as e: - return {"success": False, "error": f"HTTP error occurred: {str(e)}"} - except Exception as e: - return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} - - # Step 1: Check if the ticket is a service request - ticket_check = await get_ticket(ticket_id) - - if not ticket_check.get("success", False): - return ticket_check # If ticket fetching or type check failed, return the error message - - # Step 2: If the ticket is a service request, fetch the requested items - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/requested_items" - headers = get_auth_headers() # Use your existing method to get the headers - - async with httpx.AsyncClient() as client: - try: - # Send GET request to fetch requested items - response = await client.get(url, headers=headers) - response.raise_for_status() # Will raise HTTPError for bad responses - - # If the response contains requested items, return them - if response.status_code == 200: - return response.json() - - except httpx.HTTPStatusError as e: - # If a 400 error occurs, return a message saying no service items exist - if e.response.status_code == 400: - return {"success": False, "error": "There are no service items for this ticket"} - return {"success": False, "error": f"HTTP error occurred: {str(e)}"} - except Exception as e: - return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} - -#CREATE SERVICE REQUEST -@mcp.tool() -async def create_service_request( - display_id: int, - email: str, - requested_for: Optional[str] = None, - quantity: int = 1 -) -> dict: - """Create a service request in Freshservice.""" - if not isinstance(quantity, int) or quantity <= 0: - return {"success": False, "error": "Quantity must be a positive integer."} - - if requested_for and "@" not in requested_for: - return {"success": False, "error": "requested_for must be a valid email address."} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/service_catalog/items/{display_id}/place_request" - - payload = { - "email": email, - "quantity": quantity - } - - if requested_for: - payload["requested_for"] = requested_for - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=payload) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - error_message = f"Failed to place request: {str(e)}" - try: - error_details = e.response.json() - return {"success": False, "error": error_details} - except Exception: - return {"success": False, "error": error_message} - except Exception as e: - return {"success": False, "error": str(e)} - -#SEND TICKET REPLY -@mcp.tool() -async def send_ticket_reply( - ticket_id: int, - body: str, - from_email: Optional[str] = None, - user_id: Optional[int] = None, - cc_emails: Optional[Union[str, List[str]]] = None, - bcc_emails: Optional[Union[str, List[str]]] = None -) -> dict: - """ - Send reply to a ticket in Freshservice.""" - - # Validation - if not ticket_id or not isinstance(ticket_id, int) or ticket_id < 1: - return {"success": False, "error": "Invalid ticket_id: Must be an integer >= 1"} - - if not body or not isinstance(body, str) or not body.strip(): - return {"success": False, "error": "Missing or empty body: Reply content is required"} - - def parse_emails(value): - if isinstance(value, str): - try: - return json.loads(value) - except json.JSONDecodeError: - return [] # Invalid JSON format - return value or [] - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/reply" - - payload = { - "body": body.strip(), - "from_email": from_email or f"helpdesk@{FRESHSERVICE_DOMAIN}", - } - - if user_id is not None: - payload["user_id"] = user_id - - parsed_cc = parse_emails(cc_emails) - if parsed_cc: - payload["cc_emails"] = parsed_cc - - parsed_bcc = parse_emails(bcc_emails) - if parsed_bcc: - payload["bcc_emails"] = parsed_bcc - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, json=payload, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - return {"success": False, "error": f"HTTP error occurred: {str(e)}"} - except Exception as e: - return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} - -#CREATE A Note -@mcp.tool() -async def create_ticket_note(ticket_id: int,body: str)-> Dict[str, Any]: - """Create a note for a ticket in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/notes" - headers = get_auth_headers() - data = { - "body": body - } - async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, json=data) - return response.json() - - #UPDATE A CONVERSATION - -#UPDATE TICKET CONVERSATION -@mcp.tool() -async def update_ticket_conversation(conversation_id: int,body: str)-> Dict[str, Any]: - """Update a conversation for a ticket in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/conversations/{conversation_id}" - headers = get_auth_headers() - data = { - "body": body - } - async with httpx.AsyncClient() as client: - response = await client.put(url, headers=headers, json=data) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot update conversation ${response.json()}" - -#GET ALL TICKET CONVERSATION -@mcp.tool() -async def list_all_ticket_conversation(ticket_id: int)-> Dict[str, Any]: - """List all conversation of a ticket in freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/conversations" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch ticket conversations ${response.json()}" - -#GET ALL PRODUCTS -@mcp.tool() -async def get_all_products(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: - """List all the products from Freshservice.""" - if page < 1: - return {"error": "Page number must be greater than 0"} - - if per_page < 1 or per_page > 100: - return {"error": "Page size must be between 1 and 100"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products" - headers = get_auth_headers() - - params = { - "page": page, - "per_page": per_page - } - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - data = response.json() - products = data.get("products", []) - - link_header = response.headers.get("Link", "") - pagination_info = parse_link_header(link_header) - next_page = pagination_info.get("next") - - return { - "success": True, - "products": products, - "pagination": { - "current_page": page, - "next_page": next_page, - "has_next": bool(next_page), - "per_page": per_page - } - } - - except httpx.HTTPStatusError as e: - return {"success": False, "error": f"HTTP error occurred: {str(e)}"} - except Exception as e: - return {"success": False, "error": f"Unexpected error occurred: {str(e)}"} - -#GET PRODUCT BY ID -@mcp.tool() -async def get_products_by_id(product_id:int)-> Dict[str, Any]: - """Get product by product ID in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products/{product_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch products from the freshservice ${response.json()}" - -#CREATE PRODUCT -@mcp.tool() -async def create_product( - name: str, - asset_type_id: int, - manufacturer: Optional[str] = None, - status: Optional[Union[str, int]] = None, - mode_of_procurement: Optional[str] = None, - depreciation_type_id: Optional[int] = None, - description: Optional[str] = None, - description_text: Optional[str] = None -) -> Dict[str, Any]: - """Create a product in Freshservice.""" - - # Allowed statuses mapping - allowed_statuses = { - "In Production": "In Production", - "In Pipeline": "In Pipeline", - "Retired": "Retired", - 1: "In Production", - 2: "In Pipeline", - 3: "Retired" - } - - # Validate status - if status is not None: - if status not in allowed_statuses: - return { - "success": False, - "error": ( - "Invalid 'status'. It should be one of: " - "[\"In Production\", 1], [\"In Pipeline\", 2], [\"Retired\", 3]" - ) - } - status = allowed_statuses[status] - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products" - headers = get_auth_headers() - - payload = { - "name": name, - "asset_type_id": asset_type_id - } - - if manufacturer: - payload["manufacturer"] = manufacturer - if status: - payload["status"] = status - if mode_of_procurement: - payload["mode_of_procurement"] = mode_of_procurement - if depreciation_type_id: - payload["depreciation_type_id"] = depreciation_type_id - if description: - payload["description"] = description - if description_text: - payload["description_text"] = description_text - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=payload) - response.raise_for_status() - return {"success": True, "data": response.json()} - except httpx.HTTPStatusError as http_err: - return { - "success": False, - "status_code": response.status_code, - "error": f"HTTP error occurred: {http_err}", - "response": response.json() - } - except Exception as err: - return { - "success": False, - "error": f"An unexpected error occurred: {err}" - } - -#UPDATE PRODUCT -@mcp.tool() -async def update_product( - id: int, - name: str, - asset_type_id: int, - manufacturer: Optional[str] = None, - status: Optional[Union[str, int]] = None, - mode_of_procurement: Optional[str] = None, - depreciation_type_id: Optional[int] = None, - description: Optional[str] = None, - description_text: Optional[str] = None -) -> Dict[str, Any]: - """Update a product in Freshservice.""" - - allowed_statuses = { - "In Production": "In Production", - "In Pipeline": "In Pipeline", - "Retired": "Retired", - 1: "In Production", - 2: "In Pipeline", - 3: "Retired" - } - - if status is not None: - if status not in allowed_statuses: - return { - "success": False, - "error": ( - "Invalid 'status'. It should be one of: " - "[\"In Production\", 1], [\"In Pipeline\", 2], [\"Retired\", 3]" - ) - } - status = allowed_statuses[status] - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products/{id}" - headers = get_auth_headers() - - payload = { - "name": name, - "asset_type_id": asset_type_id - } - - # Optional updates - if manufacturer: - payload["manufacturer"] = manufacturer - if status: - payload["status"] = status - if mode_of_procurement: - payload["mode_of_procurement"] = mode_of_procurement - if depreciation_type_id: - payload["depreciation_type_id"] = depreciation_type_id - if description: - payload["description"] = description - if description_text: - payload["description_text"] = description_text - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=payload) - response.raise_for_status() - return {"success": True, "data": response.json()} - except httpx.HTTPStatusError as http_err: - return { - "success": False, - "status_code": response.status_code, - "error": f"HTTP error occurred: {http_err}", - "response": response.json() - } - except Exception as err: - return { - "success": False, - "error": f"Unexpected error occurred: {err}" - } - -#CREATE REQUESTER -@mcp.tool() -async def create_requester( - first_name: str, - last_name: Optional[str] = None, - job_title: Optional[str] = None, - primary_email: Optional[str] = None, - secondary_emails: Optional[List[str]] = None, - work_phone_number: Optional[str] = None, - mobile_phone_number: Optional[str] = None, - department_ids: Optional[List[int]] = None, - can_see_all_tickets_from_associated_departments: Optional[bool] = None, - reporting_manager_id: Optional[int] = None, - address: Optional[str] = None, - time_zone: Optional[str] = None, - time_format: Optional[str] = None, # "12h" or "24h" - language: Optional[str] = None, - location_id: Optional[int] = None, - background_information: Optional[str] = None, - custom_fields: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: - """Creates a requester in Freshservice.""" - - if not isinstance(first_name, str) or not first_name.strip(): - return {"success": False, "error": "'first_name' must be a non-empty string."} - - if not (primary_email or work_phone_number or mobile_phone_number): - return { - "success": False, - "error": "At least one of 'primary_email', 'work_phone_number', or 'mobile_phone_number' is required." - } - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters" - headers = get_auth_headers() - - payload: Dict[str, Any] = { - "first_name": first_name.strip() - } - - # Add optional fields if provided - optional_fields = { - "last_name": last_name, - "job_title": job_title, - "primary_email": primary_email, - "secondary_emails": secondary_emails, - "work_phone_number": work_phone_number, - "mobile_phone_number": mobile_phone_number, - "department_ids": department_ids, - "can_see_all_tickets_from_associated_departments": can_see_all_tickets_from_associated_departments, - "reporting_manager_id": reporting_manager_id, - "address": address, - "time_zone": time_zone, - "time_format": time_format, - "language": language, - "location_id": location_id, - "background_information": background_information, - "custom_fields": custom_fields - } - - payload.update({k: v for k, v in optional_fields.items() if v is not None}) - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=payload) - response.raise_for_status() - return {"success": True, "data": response.json()} - - except httpx.HTTPStatusError as http_err: - return { - "success": False, - "status_code": response.status_code, - "error": f"HTTP error: {http_err}", - "response": response.json() - } - except Exception as err: - return { - "success": False, - "error": f"Unexpected error: {err}" - } - -#GET ALL REQUESTER -@mcp.tool() -async def get_all_requesters(page: int = 1, per_page: int = 30) -> Dict[str, Any]: - """Fetch all requesters from Freshservice.""" - if page < 1: - return {"success": False, "error": "Page number must be greater than 0"} - - if per_page < 1 or per_page > 100: - return {"success": False, "error": "Page size must be between 1 and 100"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters" - headers = get_auth_headers() - params = {"page": page, "per_page": per_page} - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - data = response.json() - requesters = data.get("requesters", []) - - link_header = response.headers.get("Link", "") - pagination_info = parse_link_header(link_header) - - return { - "success": True, - "requesters": requesters, - "pagination": { - "current_page": page, - "per_page": per_page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - "has_more": pagination_info.get("next") is not None - } - } - except httpx.HTTPStatusError as e: - return {"success": False, "error": f"HTTP error: {str(e)}"} - except Exception as e: - return {"success": False, "error": f"Unexpected error: {str(e)}"} - -#GET REQUESTERS BY ID -@mcp.tool() -async def get_requester_id(requester_id:int)-> Dict[str, Any]: - """Get requester by ID in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters/{requester_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch requester from the freshservice ${response.json()}" - -#LIST ALL REQUESTER FIELDS -@mcp.tool() -async def list_all_requester_fields()-> Dict[str, Any]: - """List all requester fields in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_fields" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch requester from the freshservice ${response.json()}" - -#UPDATE REQUESTER -@mcp.tool() -async def update_requester( - requester_id: int, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - job_title: Optional[str] = None, - primary_email: Optional[str] = None, - secondary_emails: Optional[List[str]] = None, - work_phone_number: Optional[int] = None, - mobile_phone_number: Optional[int] = None, - department_ids: Optional[List[int]] = None, - can_see_all_tickets_from_associated_departments: Optional[bool] = False, - reporting_manager_id: Optional[int] = None, - address: Optional[str] = None, - time_zone: Optional[str] = None, - time_format: Optional[str] = None, - language: Optional[str] = None, - location_id: Optional[int] = None, - background_information: Optional[str] = None, - custom_fields: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: - """Update a requester in Freshservice.""" - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters/{requester_id}" - headers = get_auth_headers() - - payload = { - "first_name": first_name, - "last_name": last_name, - "job_title": job_title, - "primary_email": primary_email, - "secondary_emails": secondary_emails, - "work_phone_number": work_phone_number, - "mobile_phone_number": mobile_phone_number, - "department_ids": department_ids, - "can_see_all_tickets_from_associated_departments": can_see_all_tickets_from_associated_departments, - "reporting_manager_id": reporting_manager_id, - "address": address, - "time_zone": time_zone, - "time_format": time_format, - "language": language, - "location_id": location_id, - "background_information": background_information, - "custom_fields": custom_fields - } - - data = {k: v for k, v in payload.items() if v is not None} - - async with httpx.AsyncClient() as client: - response = await client.put(url, headers=headers, json=data) - if response.status_code == 200: - return response.json() - else: - return {"success": False, "error": response.text, "status_code": response.status_code} - -#FILTER REQUESTERS -@mcp.tool() -async def filter_requesters(query: str,include_agents: bool = False) -> Dict[str, Any]: - """Filter requesters in Freshservice.""" - encoded_query = urllib.parse.quote(query) - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters?query={encoded_query}" - - if include_agents: - url += "&include_agents=true" - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - if response.status_code == 200: - return response.json() - else: - return { - "error": f"Failed to filter requesters: {response.status_code}", - "details": response.text - } - -#CREATE AN AGENT -@mcp.tool() -async def create_agent( - first_name: str, - email: str = None, - last_name: Optional[str] = None, - occasional: Optional[bool] = False, - job_title: Optional[str] = None, - work_phone_number: Optional[int] = None, - mobile_phone_number: Optional[int] = None, -) -> Dict[str, Any]: - """Create a new agent in Freshservice.""" - - data = AgentInput( - first_name=first_name, - last_name=last_name, - occasional=occasional, - job_title=job_title, - email=email, - work_phone_number=work_phone_number, - mobile_phone_number=mobile_phone_number - ).dict(exclude_none=True) - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, json=data) - if response.status_code == 200 or response.status_code == 201: - return response.json() - else: - return { - "error": f"Failed to create agent", - "status_code": response.status_code, - "details": response.json() - } - -#GET AN AGENT -@mcp.tool() -async def get_agent(agent_id:int)-> Dict[str, Any]: - """Get agent by id in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents/{agent_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch requester from the freshservice ${response.json()}" - -#GET ALL AGENTS -@mcp.tool() -async def get_all_agents(page: int = 1, per_page: int = 30) -> Dict[str, Any]: - """Fetch agents from Freshservice.""" - if page < 1: - return {"success": False, "error": "Page number must be greater than 0"} - - if per_page < 1 or per_page > 100: - return {"success": False, "error": "Page size must be between 1 and 100"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents" - headers = get_auth_headers() - params = {"page": page, "per_page": per_page} - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - data = response.json() - agents = data.get("agents", []) - - # Parse pagination info from Link header - link_header = response.headers.get("Link", "") - pagination_info = parse_link_header(link_header) - - return { - "success": True, - "agents": agents, - "pagination": { - "current_page": page, - "per_page": per_page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - "has_more": pagination_info.get("next") is not None - } - } - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to get all agents: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - -#FILTER AGENTS -@mcp.tool() -async def filter_agents(query: str) -> List[Dict[str, Any]]: - """Filter Freshservice agents based on a query.""" - base_url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents" - headers = get_auth_headers() - all_agents = [] - page = 1 - # Freshservice API requires the query to be wrapped in double quotes - encoded_query = urllib.parse.quote(f'"{query}"') - - async with httpx.AsyncClient() as client: - while True: - url = f"{base_url}?query={encoded_query}&page={page}" - response = await client.get(url, headers=headers) - response.raise_for_status() - - data = response.json() - all_agents.extend(data.get("agents", [])) - - link_header = response.headers.get("link") - pagination = parse_link_header(link_header) - - if not pagination.get("next"): - break - page = pagination["next"] - - return all_agents - -#UPDATE AGENT -@mcp.tool() -async def update_agent(agent_id, occasional=None, email=None, department_ids=None, - can_see_all_tickets_from_associated_departments=None, reporting_manager_id=None, - address=None, time_zone=None, time_format=None, language=None, - location_id=None, background_information=None, scoreboard_level_id=None): - """Update the agent details in the Freshservice.""" - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents/{agent_id}" - headers = get_auth_headers() - - payload = { - "occasional": occasional, - "email": email, - "department_ids": department_ids, - "can_see_all_tickets_from_associated_departments": can_see_all_tickets_from_associated_departments, - "reporting_manager_id": reporting_manager_id, - "address": address, - "time_zone": time_zone, - "time_format": time_format, - "language": language, - "location_id": location_id, - "background_information": background_information, - "scoreboard_level_id": scoreboard_level_id - } - - payload = {k: v for k, v in payload.items() if v is not None} - - async with httpx.AsyncClient() as client: - response = await client.put(url, headers=headers,json=payload) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch agents from the freshservice ${response.json()}" - -#GET AGENT FIELDS -@mcp.tool() -async def get_agent_fields()-> Dict[str, Any]: - """Get all agent fields in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agent_fields" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch agents from the freshservice ${response.json()}" - -#GET ALL AGENT GROUPS -@mcp.tool() -async def get_all_agent_groups()-> Dict[str, Any]: - """Get all agent groups in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch agents from the freshservice ${response.json()}" - -#GET AGENT GROUP BY ID -@mcp.tool() -async def getAgentGroupById(group_id:int)-> Dict[str, Any]: - """Get agent groups by its group id in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups/{group_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch agents from the freshservice ${response.json()}" - -#ADD REQUESTER TO GROUP -@mcp.tool() -async def add_requester_to_group( - group_id: int, - requester_id: int -) -> Dict[str, Any]: - """Add a requester to a manual requester group in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups/{group_id}/members/{requester_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers) - response.raise_for_status() - - return {"success": f"Requester {requester_id} added to group {group_id}."} - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to add requester to group: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - -#CREATE GROUP -@mcp.tool() -async def create_group(group_data: Dict[str, Any]) -> Dict[str, Any]: - """Create a group in Freshservice.""" - if "name" not in group_data: - return {"error": "Field 'name' is required to create a group."} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=group_data) - response.raise_for_status() - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to create group: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - -#UPDATE GROUP -@mcp.tool() -async def update_group(group_id: int, group_fields: Dict[str, Any]) -> Dict[str, Any]: - """Update a group in Freshservice.""" - try: - validated_fields = GroupCreate(**group_fields) - group_data = validated_fields.model_dump(exclude_none=True) - except Exception as e: - return {"error": f"Validation error: {str(e)}"} - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups/{group_id}" - headers = get_auth_headers() - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=group_data) - response.raise_for_status() - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to update group: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - -#GET ALL REQUETER GROUPS -@mcp.tool() -async def get_all_requester_groups(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: - """Get all requester groups in Freshservice.""" - if page < 1: - return {"error": "Page number must be greater than 0"} - - if per_page < 1 or per_page > 100: - return {"error": "Page size must be between 1 and 100"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups" - headers = get_auth_headers() - - params = { - "page": page, - "per_page": per_page - } - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - # Parse the Link header for pagination info - link_header = response.headers.get('Link', '') - pagination_info = parse_link_header(link_header) - - data = response.json() - - return { - "success": True, - "requester_groups": data, - "pagination": { - "current_page": page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - "per_page": per_page - } - } - - except httpx.HTTPStatusError as e: - return {"error": f"Failed to fetch all requester groups: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - -#GET REQUETER GROUPS BY ID -@mcp.tool() -async def get_requester_groups_by_id(requester_group_id:int)-> Dict[str, Any]: - """Get requester groups in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups/{requester_group_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - status_code = response.status_code - if status_code == 200: - return response.json() - else: - return f"Cannot fetch requester group from the freshservice ${response.json()}" - -#CREATE REQUESTER GROUP -@mcp.tool() -async def create_requester_group( - name: str, - description: Optional[str] = None -) -> Dict[str, Any]: - """Create a requester group in Freshservice.""" - group_data = {"name": name} - if description: - group_data["description"] = description - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=group_data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to create requester group: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#UPDATE REQUESTER GROUP -@mcp.tool() -async def update_requester_group(id: int,name: Optional[str] = None,description: Optional[str] = None) -> Dict[str, Any]: - """Update an requester group in Freshservice.""" - group_data = {} - if name: - group_data["name"] = name - if description: - group_data["description"] = description - - if not group_data: - return {"error": "At least one field (name or description) must be provided to update."} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups/{id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=group_data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to update requester group: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - -#GET LIST OF REQUESTER GROUP MEMBERS -@mcp.tool() -async def list_requester_group_members( - group_id: int -) -> Dict[str, Any]: - """List all members of a requester group in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups/{group_id}/members" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to fetch list of requester group memebers: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET ALL CANNED RESPONSES -@mcp.tool() -async def get_all_canned_response() -> Dict[str, Any]: - """List all canned response in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_responses" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() # Will raise an exception for 4xx/5xx responses - - # Return the response JSON (list of members) - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to get all canned response folder: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET CANNED RESPONSE BY ID -@mcp.tool() -async def get_canned_response( - id: int -) -> Dict[str, Any]: - """Get a canned response in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_responses/{id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() # Will raise HTTPStatusError for 4xx/5xx responses - - # Only parse JSON if the response is not empty - if response.content: - return response.json() - else: - return {"error": "No content returned for the requested canned response."} - - except httpx.HTTPStatusError as e: - # Handle specific HTTP errors like 404, 403, etc. - if e.response.status_code == 404: - return {"error": "Canned response not found (404)"} - else: - return { - "error": f"Failed to retrieve canned response: {str(e)}", - "details": e.response.json() if e.response else None - } - - except Exception as e: - return {"error": f"Unexpected error: {str(e)}"} - -#LIST ALL CANNED RESPONSE FOLDER -@mcp.tool() -async def list_all_canned_response_folder() -> Dict[str, Any]: - """List all canned response of a folder in Freshservice.""" - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_response_folders" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to list all canned response folder: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#LIST CANNED RESPONSE FOLDER -@mcp.tool() -async def list_canned_response_folder( - id: int -) -> Dict[str, Any]: - """List canned response folder in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_response_folders/{id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to list canned response folder: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET ALL WORKSPACES -@mcp.tool() -async def list_all_workspaces() -> Dict[str, Any]: - """List all workspaces in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/workspaces" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to fetch list of solution workspaces: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET WORKSPACE -@mcp.tool() -async def get_workspace(id: int) -> Dict[str, Any]: - """Get a workspace by its ID in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/workspaces/{id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to fetch workspace: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET ALL SOLUTION CATEGORY -@mcp.tool() -async def get_all_solution_category() -> Dict[str, Any]: - """Get all solution category in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to get all solution category: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET SOLUTION CATEGORY -@mcp.tool() -async def get_solution_category(id: int) -> Dict[str, Any]: - """Get solution category by its ID in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories/{id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to get solution category: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#CREATE SOLUTION CATEGORY -@mcp.tool() -async def create_solution_category( - name: str, - description: str = None, - workspace_id: int = None, -) -> Dict[str, Any]: - """Create a new solution category in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories" - headers = get_auth_headers() - - category_data = { - "name": name, - "description": description, - "workspace_id": workspace_id, - } - - category_data = {key: value for key, value in category_data.items() if value is not None} - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=category_data) - response.raise_for_status() - - return response.json() - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to create solution category: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#UPDATE SOLUTION CATEGORY -@mcp.tool() -async def update_solution_category( - category_id: int, - name: str, - description: str = None, - workspace_id: int = None, - default_category: bool = None, -) -> Dict[str, Any]: - """Update a solution category in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories/{category_id}" - headers = get_auth_headers() - - - category_data = { - "name": name, - "description": description, - "workspace_id": workspace_id, - "default_category": default_category, - } - - - category_data = {key: value for key, value in category_data.items() if value is not None} - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=category_data) - response.raise_for_status() - - return response.json() - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to update solution category: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET LIST OF SOLUTION FOLDER -@mcp.tool() -async def get_list_of_solution_folder(id:int) -> Dict[str, Any]: - """Get list of solution folder by its ID in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders?category_id={id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to fetch list of solution folder: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET SOLUTION FOLDER -@mcp.tool() -async def get_solution_folder(id: int) -> Dict[str, Any]: - """Get solution folder by its ID in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders/{id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to fetch solution folder: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET LIST OF SOLUTION ARTICLE -@mcp.tool() -async def get_list_of_solution_article(id:int) -> Dict[str, Any]: - """Get list of solution article in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles?folder_id={id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to fetch list of solution article: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#GET SOLUTION ARTICLE -@mcp.tool() -async def get_solution_article(id:int) -> Dict[str, Any]: - """Get solution article by id in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to fetch solution article: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#CREATE SOLUTION ARTICLE -@mcp.tool() -async def create_solution_article( - title: str, - description: str, - folder_id: int, - article_type: Optional[int] = 1, # 1 - permanent, 2 - workaround - status: Optional[int] = 1, # 1 - draft, 2 - published - tags: Optional[List[str]] = None, - keywords: Optional[List[str]] = None, - review_date: Optional[str] = None # Format: YYYY-MM-DD -) -> Dict[str, Any]: - """Create a new solution article in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles" - headers = get_auth_headers() - - article_data = { - "title": title, - "description": description, - "folder_id": folder_id, - "article_type": article_type, - "status": status, - "tags": tags, - "keywords": keywords, - "review_date": review_date - } - - article_data = {key: value for key, value in article_data.items() if value is not None} - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=article_data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to create solution article: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#UPDATE SOLUTION ARTICLE -@mcp.tool() -async def update_solution_article( - article_id: int, - title: Optional[str] = None, - description: Optional[str] = None, - folder_id: Optional[int] = None, - article_type: Optional[int] = None, # 1 - permanent, 2 - workaround - status: Optional[int] = None, # 1 - draft, 2 - published - tags: Optional[List[str]] = None, - keywords: Optional[List[str]] = None, - review_date: Optional[str] = None # Format: YYYY-MM-DD -) -> Dict[str, Any]: - """Update a solution article in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{article_id}" - headers = get_auth_headers() - - update_data = { - "title": title, - "description": description, - "folder_id": folder_id, - "article_type": article_type, - "status": status, - "tags": tags, - "keywords": keywords, - "review_date": review_date - } - - update_data = {key: value for key, value in update_data.items() if value is not None} - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=update_data) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to update solution article: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#CREATE SOLUTION FOLDER -@mcp.tool() -async def create_solution_folder( - name: str, - category_id: int, - department_ids: List[int], - visibility: int = 4, - description: Optional[str] = None -) -> Dict[str, Any]: - """Create a new folder under a solution category in Freshservice.""" - - if not department_ids: - return {"error": "department_ids must be provided and cannot be empty."} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders" - headers = get_auth_headers() - - payload = { - "name": name, - "category_id": category_id, - "visibility": visibility, # Allowed values: 1, 2, 3, 4, 5, 6, 7 - "description": description, - "department_ids": department_ids - } - - payload = {k: v for k, v in payload.items() if v is not None} - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=payload) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to create solution folder: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#UPDATE SOLUTION FOLDER -@mcp.tool() -async def update_solution_folder( - id: int, - name: Optional[str] = None, - description: Optional[str] = None, - visibility: Optional[int] = None # Allowed values: 1, 2, 3, 4, 5, 6, 7 -) -> Dict[str, Any]: - """Update an existing solution folder's details in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders/{id}" - headers = get_auth_headers() - - payload = { - "name": name, - "description": description, - "visibility": visibility - } - - payload = {k: v for k, v in payload.items() if v is not None} - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=payload) - response.raise_for_status() - return response.json() - - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to update solution folder: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - -#PUBLISH SOLUTION ARTICLE -@mcp.tool() -async def publish_solution_article(article_id: int) -> Dict[str, Any]: - """Publish a solution article in Freshservice.""" - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{article_id}" - headers = get_auth_headers() - - payload = {"status": 2} - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers,json=payload) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - error_text = None - try: - error_text = e.response.json() if e.response else None - except Exception: - error_text = e.response.text if e.response else None - - return { - "error": f"Failed to publish solution article: {str(e)}", - "status_code": e.response.status_code if e.response else None, - "details": error_text - } - - except Exception as e: - return { - "error": f"Unexpected error occurred: {str(e)}" - } - - -# ============================================================================ -# ASSETS / CMDB MANAGEMENT -# ============================================================================ - -#GET ASSETS -@mcp.tool() -async def get_assets( - page: Optional[int] = 1, - per_page: Optional[int] = 30, - include: Optional[str] = None, - order_by: Optional[str] = None, - order_type: Optional[str] = None, - trashed: Optional[bool] = False, - workspace_id: Optional[int] = None -) -> Dict[str, Any]: - """Get all assets from Freshservice with pagination support. - - Args: - page: Page number (default: 1) - per_page: Number of items per page (1-100, default: 30) - include: Embed additional details. Use 'type_fields' to get asset type specific fields (costs 1 extra API credit) - order_by: Sort field: 'id', 'created_at', or 'updated_at' (default: created_at) - order_type: Sort order: 'asc' or 'desc' (default: desc) - trashed: If True, return only trashed assets (default: False) - workspace_id: Filter by workspace ID. Use 0 for all workspaces. If not provided, returns assets from primary workspace only. - """ - if page < 1: - return {"error": "Page number must be greater than 0"} - - if per_page < 1 or per_page > 100: - return {"error": "Page size must be between 1 and 100"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets" - - params = { - "page": page, - "per_page": per_page - } - - if include: - params["include"] = include - if order_by: - params["order_by"] = order_by - if order_type: - params["order_type"] = order_type - if trashed: - params["trashed"] = "true" - if workspace_id is not None: - params["workspace_id"] = workspace_id - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - link_header = response.headers.get('Link', '') - pagination_info = parse_link_header(link_header) - - assets = response.json() - - return { - "assets": assets, - "pagination": { - "current_page": page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - "per_page": per_page - } - } - - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET ASSET BY ID -@mcp.tool() -async def get_asset_by_id( - display_id: int, - include: Optional[str] = None -) -> Dict[str, Any]: - """Get a specific asset by its display ID in Freshservice. - - Args: - display_id: The display ID of the asset to retrieve - include: Embed additional details. Use 'type_fields' to get asset type specific fields (costs 1 extra API credit) - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" - - params = {} - if include: - params["include"] = include - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch asset: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch asset: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#CREATE ASSET -@mcp.tool() -async def create_asset( - name: str, - asset_type_id: int, - asset_tag: Optional[str] = None, - impact: Optional[str] = "low", - usage_type: Optional[str] = "permanent", - description: Optional[str] = None, - user_id: Optional[int] = None, - location_id: Optional[int] = None, - department_id: Optional[int] = None, - agent_id: Optional[int] = None, - group_id: Optional[int] = None, - assigned_on: Optional[str] = None, - workspace_id: Optional[int] = None, - type_fields: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: - """Create a new asset in Freshservice. - - Args: - name: Name of the asset (MANDATORY) - asset_type_id: ID of the asset type (MANDATORY) - asset_tag: Asset tag (e.g., 'ASSET-9') - impact: Impact level: 'low', 'medium', or 'high' (default: 'low') - usage_type: Usage type: 'permanent' or 'loaner' (default: 'permanent') - description: Description of the asset - user_id: ID of the user the asset is assigned to (Used By) - location_id: ID of the associated location - department_id: ID of the associated department - agent_id: ID of the agent managing the asset (Managed By) - group_id: ID of the agent group managing the asset (Managed By Group) - assigned_on: Date when asset was assigned (ISO format) - workspace_id: Workspace ID (applicable only to accounts with workspaces) - type_fields: Asset type specific fields (e.g., product_id, vendor_id, serial_number, cost, etc.) - """ - if impact and impact not in ("low", "medium", "high"): - return {"error": "Invalid impact value. Must be 'low', 'medium', or 'high'"} - - if usage_type and usage_type not in ("permanent", "loaner"): - return {"error": "Invalid usage_type value. Must be 'permanent' or 'loaner'"} - - data: Dict[str, Any] = { - "name": name, - "asset_type_id": asset_type_id - } - - if asset_tag: - data["asset_tag"] = asset_tag - if impact: - data["impact"] = impact - if usage_type: - data["usage_type"] = usage_type - if description: - data["description"] = description - if user_id is not None: - data["user_id"] = user_id - if location_id is not None: - data["location_id"] = location_id - if department_id is not None: - data["department_id"] = department_id - if agent_id is not None: - data["agent_id"] = agent_id - if group_id is not None: - data["group_id"] = group_id - if assigned_on: - data["assigned_on"] = assigned_on - if workspace_id is not None: - data["workspace_id"] = workspace_id - if type_fields: - data["type_fields"] = type_fields - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json=data) - response.raise_for_status() - return { - "success": True, - "message": "Asset created successfully", - "asset": response.json() - } - except httpx.HTTPStatusError as e: - error_message = f"Failed to create asset: {str(e)}" - try: - error_details = e.response.json() - if "errors" in error_details: - error_message = f"Validation errors: {error_details['errors']}" - except Exception: - pass - return {"success": False, "error": error_message} - except Exception as e: - return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} - - -#UPDATE ASSET -@mcp.tool() -async def update_asset( - display_id: int, - asset_fields: Dict[str, Any] -) -> Dict[str, Any]: - """Update an existing asset in Freshservice. - - Args: - display_id: The display ID of the asset to update - asset_fields: Dictionary of fields to update. Supported fields include: - - name: Name of the asset - - asset_type_id: ID of the asset type - - asset_tag: Asset tag - - impact: 'low', 'medium', or 'high' - - usage_type: 'permanent' or 'loaner' - - description: Description - - user_id: User ID (Used By) - - location_id: Location ID - - department_id: Department ID - - agent_id: Agent ID (Managed By) - - group_id: Agent group ID (Managed By Group) - - assigned_on: Assignment date (ISO format) - - type_fields: Asset type specific fields dict - - Note: workspace_id cannot be updated here. Use move_asset instead. - """ - if not asset_fields: - return {"error": "No fields provided for update"} - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=asset_fields) - response.raise_for_status() - return { - "success": True, - "message": "Asset updated successfully", - "asset": response.json() - } - except httpx.HTTPStatusError as e: - error_message = f"Failed to update asset: {str(e)}" - try: - error_details = e.response.json() - if "errors" in error_details: - error_message = f"Validation errors: {error_details['errors']}" - except Exception: - pass - return {"success": False, "error": error_message} - except Exception as e: - return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} - - -#DELETE ASSET (move to trash) -@mcp.tool() -async def delete_asset(display_id: int) -> str: - """Delete an asset in Freshservice (moves it to trash). - - Args: - display_id: The display ID of the asset to delete - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.delete(url, headers=headers) - - if response.status_code == 204: - return "Asset deleted successfully (moved to trash)" - elif response.status_code == 404: - return "Error: Asset not found" - else: - try: - response_data = response.json() - return f"Error: {response_data.get('error', 'Failed to delete asset')}" - except ValueError: - return "Error: Unexpected response format" - - -#DELETE ASSET PERMANENTLY -@mcp.tool() -async def delete_asset_permanently(display_id: int) -> str: - """Permanently delete an asset from Freshservice. This action cannot be undone. - - Args: - display_id: The display ID of the trashed asset to permanently delete - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/delete_forever" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.put(url, headers=headers) - - if response.status_code == 204: - return "Asset permanently deleted" - elif response.status_code == 404: - return "Error: Asset not found" - else: - try: - response_data = response.json() - return f"Error: {response_data.get('error', 'Failed to permanently delete asset')}" - except ValueError: - return "Error: Unexpected response format" - - -#RESTORE ASSET -@mcp.tool() -async def restore_asset(display_id: int) -> str: - """Restore a previously deleted (trashed) asset in Freshservice. - - Args: - display_id: The display ID of the trashed asset to restore - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/restore" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - response = await client.put(url, headers=headers) - - if response.status_code == 204: - return "Asset restored successfully" - elif response.status_code == 404: - return "Error: Asset not found in trash" - else: - try: - response_data = response.json() - return f"Error: {response_data.get('error', 'Failed to restore asset')}" - except ValueError: - return "Error: Unexpected response format" - - -#SEARCH ASSETS -@mcp.tool() -async def search_assets( - search_query: str, - page: Optional[int] = 1, - trashed: Optional[bool] = False -) -> Dict[str, Any]: - """Search assets in Freshservice using asset attributes. - - Args: - search_query: Search query string using asset fields. - Supported fields: name, asset_tag, serial_number, mac_addresses, ip_addresses, uuid, item_id, imei_number. - Examples: "name:'dell'", "serial_number:'HSN123'", "asset_tag:'ASSET-65'" - Note: The query will be automatically URL-encoded and wrapped in double quotes. - page: Page number (default: 1). 30 results per page. - trashed: If True, search in trashed assets (default: False) - """ - encoded_search = urllib.parse.quote(f'"{search_query}"') - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets?search={encoded_search}&page={page}" - - if trashed: - url += "&trashed=true" - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - link_header = response.headers.get('Link', '') - pagination_info = parse_link_header(link_header) - total_count = response.headers.get('X-Total-Count', None) - - result = response.json() - result["pagination"] = { - "current_page": page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - } - if total_count: - result["total_count"] = int(total_count) - return result - - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#FILTER ASSETS -@mcp.tool() -async def filter_assets( - filter_query: str, - page: Optional[int] = 1, - include: Optional[str] = None -) -> Dict[str, Any]: - """Filter assets in Freshservice using asset attributes. - - Args: - filter_query: Filter query string using asset fields. - Supported fields: workspace_id, asset_type_id, department_id, location_id, - asset_state, user_id, agent_id, name, asset_tag, created_at, updated_at, - serial_number, mac_addresses, ip_addresses, uuid, item_id, imei_number, device42_id. - Operators: AND, OR, parentheses (), :> (>=), :< (<=) - Examples: - "asset_state:'IN USE'" - "asset_state:'IN STOCK' AND created_at:>'2023-01-01'" - "department_id:5 AND location_id:3" - "asset_type_id:25 AND agent_id:null" - Note: The query will be automatically URL-encoded and wrapped in double quotes. - page: Page number (default: 1). 30 results per page, max 40 pages. - include: Embed additional details. Use 'type_fields' for asset type specific fields. - """ - encoded_filter = urllib.parse.quote(f'"{filter_query}"') - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets?filter={encoded_filter}&page={page}" - - if include: - url += f"&include={include}" - - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - - link_header = response.headers.get('Link', '') - pagination_info = parse_link_header(link_header) - total_count = response.headers.get('X-Total-Count', None) - - result = response.json() - result["pagination"] = { - "current_page": page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - } - if total_count: - result["total_count"] = int(total_count) - return result - - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#MOVE ASSET TO WORKSPACE -@mcp.tool() -async def move_asset( - display_id: int, - workspace_id: int, - agent_id: Optional[int] = None, - group_id: Optional[int] = None -) -> Dict[str, Any]: - """Move an asset to a different workspace in Freshservice. - - Note: This endpoint is applicable only to accounts with workspaces. - - Args: - display_id: The display ID of the asset to move - workspace_id: The target workspace ID - agent_id: Optional new agent ID for the asset - group_id: Optional new group ID for the asset - """ - data: Dict[str, Any] = {"workspace_id": workspace_id} - if agent_id is not None: - data["agent_id"] = agent_id - if group_id is not None: - data["group_id"] = group_id - - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/move_workspace" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.put(url, headers=headers, json=data) - response.raise_for_status() - return { - "success": True, - "message": "Asset moved successfully", - "asset": response.json() - } - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to move asset: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to move asset: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET ASSET COMPONENTS -@mcp.tool() -async def get_asset_components(display_id: int) -> Dict[str, Any]: - """Get all components of an asset in Freshservice (e.g., Processor, Memory, Disk, etc.). - - Args: - display_id: The display ID of the asset - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/components" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch asset components: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch asset components: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET ASSET ASSIGNMENT HISTORY -@mcp.tool() -async def get_asset_assignment_history(display_id: int) -> Dict[str, Any]: - """Get the user assignment history for a specific asset in Freshservice. - - Args: - display_id: The display ID of the asset - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/assignment-history" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch assignment history: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch assignment history: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET ASSET REQUESTS -@mcp.tool() -async def get_asset_requests(display_id: int) -> Dict[str, Any]: - """List all associated requests (tickets) for a specific asset in Freshservice. - - Args: - display_id: The display ID of the asset - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/requests" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch asset requests: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch asset requests: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET ASSET CONTRACTS -@mcp.tool() -async def get_asset_contracts(display_id: int) -> Dict[str, Any]: - """List all associated contracts for a specific asset in Freshservice. - - Args: - display_id: The display ID of the asset - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/contracts" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch asset contracts: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch asset contracts: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -# ============================================================================ -# ASSET RELATIONSHIPS -# ============================================================================ - -#GET ASSET RELATIONSHIPS -@mcp.tool() -async def get_asset_relationships(display_id: int) -> Dict[str, Any]: - """List all relationships for a specific asset in Freshservice. - - Args: - display_id: The display ID of the asset - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/relationships" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch asset relationships: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch asset relationships: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET ALL RELATIONSHIPS -@mcp.tool() -async def get_all_relationships(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: - """List all asset relationships in the Freshservice account. - - Args: - page: Page number (default: 1) - per_page: Number of items per page (1-100, default: 30) - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships" - params = {"page": page, "per_page": per_page} - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - link_header = response.headers.get('Link', '') - pagination_info = parse_link_header(link_header) - - return { - "relationships": response.json(), - "pagination": { - "current_page": page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - "per_page": per_page - } - } - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET RELATIONSHIP BY ID -@mcp.tool() -async def get_relationship_by_id(relationship_id: int) -> Dict[str, Any]: - """View a specific relationship by its ID in Freshservice. - - Args: - relationship_id: The ID of the relationship - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships/{relationship_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch relationship: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch relationship: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#CREATE RELATIONSHIPS IN BULK -@mcp.tool() -async def create_asset_relationships( - relationships: List[Dict[str, Any]] -) -> Dict[str, Any]: - """Create asset relationships in bulk in Freshservice. - - This is an asynchronous operation performed via background jobs. - The response contains a job_id which can be used with get_job_status() to track progress. - Supported primary_type/secondary_type values: 'asset', 'requester', 'agent', 'department', 'software'. - - Args: - relationships: List of relationship objects. Each object should contain: - - relationship_type_id: ID of the relationship type (MANDATORY) - - primary_id: ID of the primary entity (MANDATORY) - - primary_type: Type of the primary entity: 'asset', 'requester', 'agent', 'department', 'software' (MANDATORY) - - secondary_id: ID of the secondary entity (MANDATORY) - - secondary_type: Type of the secondary entity: 'asset', 'requester', 'agent', 'department', 'software' (MANDATORY) - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships/bulk-create" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.post(url, headers=headers, json={"relationships": relationships}) - response.raise_for_status() - result = response.json() - return { - "success": True, - "message": "Bulk relationship creation job submitted. Use get_job_status() with the job_id to track progress.", - "job_id": result.get("job_id"), - "href": result.get("href"), - "data": result - } - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to create relationships: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to create relationships: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#DELETE RELATIONSHIPS IN BULK -@mcp.tool() -async def delete_asset_relationships( - relationship_ids: List[int] -) -> str: - """Delete asset relationships in bulk in Freshservice. - - Args: - relationship_ids: List of relationship IDs to delete - """ - ids_param = ",".join(str(rid) for rid in relationship_ids) - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships?ids={ids_param}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.delete(url, headers=headers) - - if response.status_code == 204: - return "Relationships deleted successfully" - else: - try: - return f"Error: {response.json()}" - except Exception: - return f"Error: Unexpected response (status {response.status_code})" - except Exception as e: - return f"Error: An unexpected error occurred: {str(e)}" - - -#GET BACKGROUND JOB STATUS -@mcp.tool() -async def get_job_status(job_id: str) -> Dict[str, Any]: - """Get the status of a background job in Freshservice. - - Use this to track the progress of async operations like bulk relationship creation. - Possible status values: 'queued', 'in progress', 'partial', 'success', 'failed'. - - queued: Job is queued and ready to be executed - - in progress: Job execution started - - partial: Job completed with some successes and some failures - - success: All operations completed successfully - - failed: No operations succeeded - - Note: The job status URL is valid for one hour after creation. - - Args: - job_id: The job ID returned by a bulk operation (e.g., create_asset_relationships) - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/jobs/{job_id}" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch job status: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch job status: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET RELATIONSHIP TYPES -@mcp.tool() -async def get_relationship_types() -> Dict[str, Any]: - """List all relationship types available in Freshservice. - These define the kind of relationships between assets (e.g., 'Used By', 'Depends On', etc.). - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationship_types" - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch relationship types: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch relationship types: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -# ============================================================================ -# ASSET TYPES -# ============================================================================ - -#GET ASSET TYPES -@mcp.tool() -async def get_asset_types(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: - """List all asset types in Freshservice (e.g., Hardware, Software, etc.). - - Args: - page: Page number (default: 1) - per_page: Number of items per page (1-100, default: 30) - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/asset_types" - params = {"page": page, "per_page": per_page} - headers = get_auth_headers() - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers, params=params) - response.raise_for_status() - - link_header = response.headers.get('Link', '') - pagination_info = parse_link_header(link_header) - - return { - "asset_types": response.json(), - "pagination": { - "current_page": page, - "next_page": pagination_info.get("next"), - "prev_page": pagination_info.get("prev"), - "per_page": per_page - } - } - except httpx.HTTPStatusError as e: - try: - return {"error": str(e), "details": e.response.json()} - except Exception: - return {"error": str(e), "raw_response": e.response.text} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - - -#GET ASSET TYPE BY ID -@mcp.tool() -async def get_asset_type_by_id(asset_type_id: int) -> Dict[str, Any]: - """Get a specific asset type by ID in Freshservice. - - Args: - asset_type_id: The ID of the asset type - """ - url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/asset_types/{asset_type_id}" - headers = get_auth_headers() + args, _unknown = parser.parse_known_args() + scopes = _resolve_scopes(args.scope) - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - try: - return {"error": f"Failed to fetch asset type: {str(e)}", "details": e.response.json()} - except Exception: - return {"error": f"Failed to fetch asset type: {str(e)}"} - except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} + # Always register discovery tools (2 lightweight tools) + register_discovery_tools(mcp) + log.info("Registered discovery tools (discover_form_fields, clear_field_cache)") + # Register requested scopes + for scope in scopes: + SCOPE_REGISTRY[scope](mcp) + log.info("Registered scope: %s", scope) -# GET AUTH HEADERS -def get_auth_headers(): - return { - "Authorization": f"Basic {base64.b64encode(f'{FRESHSERVICE_APIKEY}:X'.encode()).decode()}", - "Content-Type": "application/json" - } + total = len(mcp._tool_manager._tools) if hasattr(mcp, "_tool_manager") else "?" + log.info("Freshservice MCP server starting — %s tools loaded (scopes: %s)", total, ", ".join(scopes)) + mcp.run(transport="stdio") -def main(): - logging.info("Starting Freshservice MCP server") - mcp.run(transport='stdio') if __name__ == "__main__": main() diff --git a/src/freshservice_mcp/server_legacy.py b/src/freshservice_mcp/server_legacy.py new file mode 100644 index 0000000..7813ded --- /dev/null +++ b/src/freshservice_mcp/server_legacy.py @@ -0,0 +1,4269 @@ +import os +import re +import httpx +import logging +import base64 +import json +import urllib.parse +from typing import Optional, Dict, Union, Any, List +from mcp.server.fastmcp import FastMCP +from enum import IntEnum, Enum +from pydantic import BaseModel, Field + + +from dotenv import load_dotenv +load_dotenv() + + +# Set up logging +logging.basicConfig(level=logging.INFO) + + +# Create MCP INSTANCE +mcp = FastMCP("freshservice_mcp") + + +# API CREDENTIALS +FRESHSERVICE_DOMAIN = os.getenv("FRESHSERVICE_DOMAIN") +FRESHSERVICE_APIKEY = os.getenv("FRESHSERVICE_APIKEY") + + +class TicketSource(IntEnum): + PHONE = 3 + EMAIL = 1 + PORTAL = 2 + CHAT = 7 + YAMMER = 6 + PAGERDUTY = 8 + AWS_CLOUDWATCH = 7 + WALK_UP = 9 + SLACK=10 + WORKPLACE = 12 + EMPLOYEE_ONBOARDING = 13 + ALERTS = 14 + MS_TEAMS = 15 + EMPLOYEE_OFFBOARDING = 18 + +class TicketStatus(IntEnum): + OPEN = 2 + PENDING = 3 + RESOLVED = 4 + CLOSED = 5 + +class TicketPriority(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + URGENT = 4 + +class ChangeStatus(IntEnum): + OPEN = 1 + PLANNING = 2 + AWAITING_APPROVAL = 3 + PENDING_RELEASE = 4 + PENDING_REVIEW = 5 + CLOSED = 6 + +class ChangePriority(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + URGENT = 4 + +class ChangeImpact(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + +class ChangeType(IntEnum): + MINOR = 1 + STANDARD = 2 + MAJOR = 3 + EMERGENCY = 4 + +class ChangeRisk(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + VERY_HIGH = 4 + +class UnassignedForOptions(str, Enum): + THIRTY_MIN = "30m" + ONE_HOUR = "1h" + TWO_HOURS = "2h" + FOUR_HOURS = "4h" + EIGHT_HOURS = "8h" + TWELVE_HOURS = "12h" + ONE_DAY = "1d" + TWO_DAYS = "2d" + THREE_DAYS = "3d" + +class FilterRequestersSchema(BaseModel): + query: str = Field(..., description="Main query string to filter requesters (e.g., first_name:'Vijay')") + custom_fields: Optional[Dict[str, str]] = Field(default=None, description="Custom fields to filter (key-value pairs)") + include_agents: Optional[bool] = Field(default=False, description="Include agents in the response") + page: Optional[int] = Field(default=1, description="Page number for pagination (default is 1)") + +class AgentInput(BaseModel): + first_name: str = Field(..., description="First name of the agent") + last_name: Optional[str] = Field(None, description="Last name of the agent") + occasional: Optional[bool] = Field(False, description="True if the agent is an occasional agent") + job_title: Optional[str] = Field(None, description="Job title of the agent") + email: Optional[str]= Field(..., description="Email address of the agent") + work_phone_number: Optional[int] = Field(None, description="Work phone number of the agent") + mobile_phone_number: Optional[int] = Field(None, description="Mobile phone number of the agent") + +class GroupCreate(BaseModel): + name: str = Field(..., description="Name of the group") + description: Optional[str] = Field(None, description="Description of the group") + agent_ids: Optional[List[int]] = Field( + default=None, + description="Array of agent user ids" + ) + auto_ticket_assign: Optional[bool] = Field( + default=False, + description="Whether tickets are automatically assigned (true or false)" + ) + escalate_to: Optional[int] = Field( + None, + description="User ID to whom escalation email is sent if ticket is unassigned" + ) + unassigned_for: Optional[UnassignedForOptions] = Field( + default=UnassignedForOptions.THIRTY_MIN, + description="Time after which escalation email will be sent" + ) + +def parse_link_header(link_header: str) -> Dict[str, Optional[int]]: + """Parse the Link header to extract pagination information. + + Args: + link_header: The Link header string from the response + + Returns: + Dictionary containing next and prev page numbers + """ + pagination = { + "next": None, + "prev": None + } + + if not link_header: + return pagination + + + links = link_header.split(',') + + for link in links: + match = re.search(r'<(.+?)>;\s*rel="(.+?)"', link) + if match: + url, rel = match.groups() + page_match = re.search(r'page=(\d+)', url) + if page_match: + page_num = int(page_match.group(1)) + pagination[rel] = page_num + + return pagination + +#GET TICKET FIELDS +@mcp.tool() +async def get_ticket_fields() -> Dict[str, Any]: + """Get ticket fields from Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/ticket_form_fields" + headers = get_auth_headers() + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + return response.json() + +#GET TICKETS +@mcp.tool() +async def get_tickets(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: + """Get tickets from Freshservice with pagination support.""" + + if page < 1: + return {"error": "Page number must be greater than 0"} + + if per_page < 1 or per_page > 100: + return {"error": "Page size must be between 1 and 100"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets" + + params = { + "page": page, + "per_page": per_page + } + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + tickets = response.json() + + return { + "tickets": tickets, + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + + except httpx.HTTPStatusError as e: + return {"error": f"Failed to fetch tickets: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + +#CREATE TICKET +@mcp.tool() +async def create_ticket( + subject: str, + description: str, + source: Union[int, str], + priority: Union[int, str], + status: Union[int, str], + email: Optional[str] = None, + requester_id: Optional[int] = None, + custom_fields: Optional[Dict[str, Any]] = None +) -> str: + """Create a ticket in Freshservice.""" + + if not email and not requester_id: + return "Error: Either email or requester_id must be provided" + + try: + source_val = int(source) + priority_val = int(priority) + status_val = int(status) + except ValueError: + return "Error: Invalid value for source, priority, or status" + + if (source_val not in [e.value for e in TicketSource] or + priority_val not in [e.value for e in TicketPriority] or + status_val not in [e.value for e in TicketStatus]): + return "Error: Invalid value for source, priority, or status" + + data = { + "subject": subject, + "description": description, + "source": source_val, + "priority": priority_val, + "status": status_val + } + + if email: + data["email"] = email + if requester_id: + data["requester_id"] = requester_id + + if custom_fields: + data["custom_fields"] = custom_fields + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + + response_data = response.json() + return f"Ticket created successfully: {response_data}" + + except httpx.HTTPStatusError as e: + if e.response.status_code == 400: + error_data = e.response.json() + if "errors" in error_data: + return f"Validation Error: {error_data['errors']}" + return f"Error: Failed to create ticket - {str(e)}" + except Exception as e: + return f"Error: An unexpected error occurred - {str(e)}" + +#UPDATE TICKET +@mcp.tool() +async def update_ticket(ticket_id: int, ticket_fields: Dict[str, Any]) -> Dict[str, Any]: + """Update a ticket in Freshservice.""" + if not ticket_fields: + return {"error": "No fields provided for update"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}" + headers = get_auth_headers() + + custom_fields = ticket_fields.pop('custom_fields', {}) + + update_data = {} + + for field, value in ticket_fields.items(): + update_data[field] = value + + if custom_fields: + update_data['custom_fields'] = custom_fields + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=update_data) + response.raise_for_status() + + return { + "success": True, + "message": "Ticket updated successfully", + "ticket": response.json() + } + + except httpx.HTTPStatusError as e: + error_message = f"Failed to update ticket: {str(e)}" + try: + error_details = e.response.json() + if "errors" in error_details: + error_message = f"Validation errors: {error_details['errors']}" + except Exception: + pass + return { + "success": False, + "error": error_message + } + except Exception as e: + return { + "success": False, + "error": f"An unexpected error occurred: {str(e)}" + } + +#FILTER TICKET +@mcp.tool() +async def filter_tickets(query: str, page: int = 1, workspace_id: Optional[int] = None) -> Dict[str, Any]: + """Filter the tickets in Freshservice. + + Args: + query: Filter query string (e.g., "status:2 AND priority:1") + Note: Some Freshservice endpoints may require queries to be wrapped in double quotes. + If you get 500 errors, try wrapping your query in double quotes: "your_query_here" + page: Page number (default: 1) + workspace_id: Optional workspace ID filter + """ + # Freshservice API requires the query to be wrapped in double quotes + encoded_query = urllib.parse.quote(f'"{query}"') + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/filter?query={encoded_query}&page={page}" + + if workspace_id is not None: + url += f"&workspace_id={workspace_id}" + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#DELETE TICKET. +@mcp.tool() +async def delete_ticket(ticket_id: int) -> str: + """Delete a ticket in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.delete(url, headers=headers) + + if response.status_code == 204: + # No content returned on successful deletion + return "Ticket deleted successfully" + elif response.status_code == 404: + return "Error: Ticket not found" + else: + try: + response_data = response.json() + return f"Error: {response_data.get('error', 'Failed to delete ticket')}" + except ValueError: + return "Error: Unexpected response format" + +#GET TICKET BY ID +@mcp.tool() +async def get_ticket_by_id(ticket_id:int) -> Dict[str, Any]: + """Get a ticket in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url,headers=headers) + return response.json() + +#GET ALL CHANGES +@mcp.tool() +async def get_changes( + page: Optional[int] = 1, + per_page: Optional[int] = 30, + query: Optional[str] = None, + view: Optional[str] = None, + sort: Optional[str] = None, + order_by: Optional[str] = None, + updated_since: Optional[str] = None, + workspace_id: Optional[int] = None +) -> Dict[str, Any]: + """Get all changes from Freshservice with pagination and filtering support. + + Args: + page: Page number (default: 1) + per_page: Number of items per page (1-100, default: 30) + query: Filter query string (e.g., "priority:4 OR priority:3", "status:2 AND priority:1") + **IMPORTANT**: Query must be wrapped in double quotes for filtering to work! + Examples: "status:3", "approval_status:1 AND status:<6", "planned_start_date:>'2025-07-14'" + view: Accepts the name or ID of views (e.g., 'my_open', 'unassigned') + sort: Field to sort by (e.g., 'priority', 'created_at') + order_by: Sort order ('asc' or 'desc', default: 'desc') + updated_since: Changes updated since date (ISO format: '2024-10-19T02:00:00Z') + workspace_id: Filter by workspace ID (0 for all workspaces) + + Query examples: + - "priority:4 OR priority:3" - Urgent and High priority changes + - "priority:>3 AND group_id:11 AND status:1" - High priority open changes for group 11 + - "status:2" - Open changes + - "status:<6" - Not closed changes (statuses 1-5) + - "approval_status:1" - Approved changes + - "planned_end_date:<'2025-01-14'" - Changes with end date before specified date + + Note: Query and view parameters cannot be used together + """ + + if page < 1: + return {"error": "Page number must be greater than 0"} + + if per_page < 1 or per_page > 100: + return {"error": "Page size must be between 1 and 100"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes" + + params = { + "page": page, + "per_page": per_page + } + + if query: + params["query"] = query + if view: + params["view"] = view + if sort: + params["sort"] = sort + if order_by: + params["order_by"] = order_by + if updated_since: + params["updated_since"] = updated_since + if workspace_id is not None: + params["workspace_id"] = workspace_id + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + changes = response.json() + + return { + "changes": changes, + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + +#GET CHANGE BY ID +@mcp.tool() +async def get_change_by_id(change_id: int) -> Dict[str, Any]: + """Get a specific change by ID in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + return {"error": f"Failed to fetch change: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + +#CREATE CHANGE +@mcp.tool() +async def create_change( + requester_id: int, + subject: str, + description: str, + priority: Union[int, str], + impact: Union[int, str], + status: Union[int, str], + risk: Union[int, str], + change_type: Union[int, str], + group_id: Optional[int] = None, + agent_id: Optional[int] = None, + department_id: Optional[int] = None, + category: Optional[str] = None, + sub_category: Optional[str] = None, + item_category: Optional[str] = None, + planned_start_date: Optional[str] = None, + planned_end_date: Optional[str] = None, + reason_for_change: Optional[str] = None, + change_impact: Optional[str] = None, + rollout_plan: Optional[str] = None, + backout_plan: Optional[str] = None, + custom_fields: Optional[Dict[str, Any]] = None, + assets: Optional[List[Dict[str, Any]]] = None +) -> Dict[str, Any]: + """Create a new change in Freshservice. + + Args: + requester_id: Unique identifier of the initiator of the change (MANDATORY) + subject: Change subject (MANDATORY) + description: HTML content of the change (MANDATORY) + priority: Priority (1=Low, 2=Medium, 3=High, 4=Urgent) (MANDATORY) + impact: Impact (1=Low, 2=Medium, 3=High) (MANDATORY) + status: Status (1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, 5=Pending Review, 6=Closed) (MANDATORY) + risk: Risk (1=Low, 2=Medium, 3=High, 4=Very High) (MANDATORY) + change_type: Type (1=Minor, 2=Standard, 3=Major, 4=Emergency) (MANDATORY) + group_id: Agent group ID + agent_id: Agent ID + department_id: Department ID + category: Category of the change + sub_category: Sub-category of the change + item_category: Item category of the change + planned_start_date: Planned start date (ISO format) + planned_end_date: Planned end date (ISO format) + reason_for_change: Planning field - Reason for change (text/HTML) + change_impact: Planning field - Impact analysis (text/HTML) + rollout_plan: Planning field - Rollout plan (text/HTML) + backout_plan: Planning field - Backout plan (text/HTML) + custom_fields: Custom fields key-value pairs + assets: List of assets to associate, e.g. [{"display_id": 1}, {"display_id": 2}] + """ + + try: + priority_val = int(priority) + impact_val = int(impact) + status_val = int(status) + risk_val = int(risk) + change_type_val = int(change_type) + except ValueError: + return {"error": "Invalid value for priority, impact, status, risk, or change_type"} + + if (priority_val not in [e.value for e in ChangePriority] or + impact_val not in [e.value for e in ChangeImpact] or + status_val not in [e.value for e in ChangeStatus] or + risk_val not in [e.value for e in ChangeRisk] or + change_type_val not in [e.value for e in ChangeType]): + return {"error": "Invalid value for priority, impact, status, risk, or change_type"} + + data = { + "requester_id": requester_id, + "subject": subject, + "description": description, + "priority": priority_val, + "impact": impact_val, + "status": status_val, + "risk": risk_val, + "change_type": change_type_val + } + + if group_id: + data["group_id"] = group_id + if agent_id: + data["agent_id"] = agent_id + if department_id: + data["department_id"] = department_id + if category: + data["category"] = category + if sub_category: + data["sub_category"] = sub_category + if item_category: + data["item_category"] = item_category + if planned_start_date: + data["planned_start_date"] = planned_start_date + if planned_end_date: + data["planned_end_date"] = planned_end_date + + # Handle planning fields + planning_fields = {} + if reason_for_change: + planning_fields["reason_for_change"] = { + "description": reason_for_change + } + if change_impact: + planning_fields["change_impact"] = { + "description": change_impact + } + if rollout_plan: + planning_fields["rollout_plan"] = { + "description": rollout_plan + } + if backout_plan: + planning_fields["backout_plan"] = { + "description": backout_plan + } + + if planning_fields: + data["planning_fields"] = planning_fields + + if custom_fields: + data["custom_fields"] = custom_fields + + if assets: + data["assets"] = assets + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + if e.response.status_code == 400: + error_data = e.response.json() + if "errors" in error_data: + return {"error": f"Validation Error: {error_data['errors']}"} + return {"error": f"Failed to create change - {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred - {str(e)}"} + +#UPDATE CHANGE +@mcp.tool() +async def update_change( + change_id: int, + subject: Optional[str] = None, + description: Optional[str] = None, + priority: Optional[Union[int, str]] = None, + impact: Optional[Union[int, str]] = None, + status: Optional[Union[int, str]] = None, + risk: Optional[Union[int, str]] = None, + change_type: Optional[Union[int, str]] = None, + group_id: Optional[int] = None, + agent_id: Optional[int] = None, + department_id: Optional[int] = None, + category: Optional[str] = None, + sub_category: Optional[str] = None, + item_category: Optional[str] = None, + planned_start_date: Optional[str] = None, + planned_end_date: Optional[str] = None, + reason_for_change: Optional[str] = None, + change_impact: Optional[str] = None, + rollout_plan: Optional[str] = None, + backout_plan: Optional[str] = None, + custom_fields: Optional[Dict[str, Any]] = None, + assets: Optional[List[Dict[str, Any]]] = None +) -> Dict[str, Any]: + """Update an existing change in Freshservice. + + Args: + change_id: The ID of the change to update + subject: Change subject + description: HTML content of the change + priority: Priority (1=Low, 2=Medium, 3=High, 4=Urgent) + impact: Impact (1=Low, 2=Medium, 3=High) + status: Status (1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, 5=Pending Review, 6=Closed) + risk: Risk (1=Low, 2=Medium, 3=High, 4=Very High) + change_type: Type (1=Minor, 2=Standard, 3=Major, 4=Emergency) + group_id: Agent group ID + agent_id: Agent ID + department_id: Department ID + category: Category of the change + sub_category: Sub-category of the change + item_category: Item category of the change + planned_start_date: Planned start date (ISO format) + planned_end_date: Planned end date (ISO format) + reason_for_change: Planning field - Reason for change (text/HTML) + change_impact: Planning field - Impact analysis (text/HTML) + rollout_plan: Planning field - Rollout plan (text/HTML) + backout_plan: Planning field - Backout plan (text/HTML) + custom_fields: Custom fields key-value pairs + assets: List of assets to associate, e.g. [{"display_id": 1}, {"display_id": 2}] + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" + headers = get_auth_headers() + + update_data = {} + + # Add regular fields if provided + if subject is not None: + update_data["subject"] = subject + if description is not None: + update_data["description"] = description + if group_id is not None: + update_data["group_id"] = group_id + if agent_id is not None: + update_data["agent_id"] = agent_id + if department_id is not None: + update_data["department_id"] = department_id + if category is not None: + update_data["category"] = category + if sub_category is not None: + update_data["sub_category"] = sub_category + if item_category is not None: + update_data["item_category"] = item_category + if planned_start_date is not None: + update_data["planned_start_date"] = planned_start_date + if planned_end_date is not None: + update_data["planned_end_date"] = planned_end_date + + # Handle enum fields with validation + if priority is not None: + try: + update_data["priority"] = int(priority) + except ValueError: + return {"error": f"Invalid priority value: {priority}"} + if impact is not None: + try: + update_data["impact"] = int(impact) + except ValueError: + return {"error": f"Invalid impact value: {impact}"} + if status is not None: + try: + update_data["status"] = int(status) + except ValueError: + return {"error": f"Invalid status value: {status}"} + if risk is not None: + try: + update_data["risk"] = int(risk) + except ValueError: + return {"error": f"Invalid risk value: {risk}"} + if change_type is not None: + try: + update_data["change_type"] = int(change_type) + except ValueError: + return {"error": f"Invalid change_type value: {change_type}"} + + # Add custom fields if present + if custom_fields: + update_data["custom_fields"] = custom_fields + + # Add assets if present + if assets: + update_data["assets"] = assets + + # Handle planning fields + planning_fields = {} + if reason_for_change is not None: + planning_fields["reason_for_change"] = {"description": reason_for_change} + if change_impact is not None: + planning_fields["change_impact"] = {"description": change_impact} + if rollout_plan is not None: + planning_fields["rollout_plan"] = {"description": rollout_plan} + if backout_plan is not None: + planning_fields["backout_plan"] = {"description": backout_plan} + + if planning_fields: + update_data["planning_fields"] = planning_fields + + if not update_data: + return {"error": "No fields provided for update"} + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=update_data) + response.raise_for_status() + + return { + "success": True, + "message": "Change updated successfully", + "change": response.json() + } + + except httpx.HTTPStatusError as e: + error_message = f"Failed to update change: {str(e)}" + try: + error_details = e.response.json() + if "errors" in error_details: + error_message = f"Validation errors: {error_details['errors']}" + except Exception: + pass + return { + "success": False, + "error": error_message + } + except Exception as e: + return { + "success": False, + "error": f"An unexpected error occurred: {str(e)}" + } + +#CLOSE CHANGE WITH RESULT +@mcp.tool() +async def close_change( + change_id: int, + change_result_explanation: str, + custom_fields: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """Close a change and provide the result explanation. + This is a convenience function that updates status to Closed and sets the result explanation.""" + + merged_custom_fields = { + "change_result_explanation": change_result_explanation + } + + # Merge additional custom fields if provided + if custom_fields: + merged_custom_fields.update(custom_fields) + + return await update_change( + change_id=change_id, + status=ChangeStatus.CLOSED.value, + custom_fields=merged_custom_fields + ) + +#DELETE CHANGE +@mcp.tool() +async def delete_change(change_id: int) -> str: + """Delete a change in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.delete(url, headers=headers) + + if response.status_code == 204: + return "Change deleted successfully" + elif response.status_code == 404: + return "Error: Change not found" + else: + try: + response_data = response.json() + return f"Error: {response_data.get('error', 'Failed to delete change')}" + except ValueError: + return "Error: Unexpected response format" + + +# FILTER CHANGES +@mcp.tool() +async def filter_changes( + query: str, + page: int = 1, + per_page: int = 30, + sort: Optional[str] = None, + order_by: Optional[str] = None, + workspace_id: Optional[int] = None +) -> Dict[str, Any]: + """Filter changes in Freshservice based on a query. + + Args: + query: Filter query string (e.g., "status:2 AND priority:1" or "approval_status:1 AND planned_end_date:<'2025-01-14' AND status:<6") + **CRITICAL**: Query must be wrapped in double quotes for filtering to work! + Without quotes: status:3 → 500 Internal Server Error + With quotes: "status:3" → Works perfectly + page: Page number (default: 1) + per_page: Number of items per page (1-100, default: 30) + sort: Field to sort by + order_by: Sort order ('asc' or 'desc') + workspace_id: Optional workspace ID filter + + Common query examples: + - "status:2" - Open changes + - "status:<6" - Not closed changes (statuses 1-5) + - "approval_status:1" - Approved changes + - "planned_end_date:<'2025-01-14'" - Changes with end date before specified date + - "priority:1 AND status:2" - High priority open changes + - "approval_status:1 AND status:3" - Approved changes awaiting implementation + """ + # Use the main get_changes function with query parameter + # This is the correct approach since /api/v2/changes/filter doesn't exist + return await get_changes( + page=page, + per_page=per_page, + query=query, + sort=sort, + order_by=order_by, + workspace_id=workspace_id + ) + +#GET CHANGE TASKS +@mcp.tool() +async def get_change_tasks(change_id: int) -> Dict[str, Any]: + """Get all tasks associated with a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + return {"error": f"Failed to fetch change tasks: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + +#CREATE CHANGE NOTE +@mcp.tool() +async def create_change_note(change_id: int, body: str) -> Dict[str, Any]: + """Create a note for a change in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes" + headers = get_auth_headers() + data = { + "body": body + } + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + return {"error": f"Failed to create change note: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + +# CHANGES APPROVAL ENDPOINTS + +#CREATE CHANGE APPROVAL GROUP +@mcp.tool() +async def create_change_approval_group( + change_id: int, + name: str, + approver_ids: List[int], + approval_type: str = "everyone" +) -> Dict[str, Any]: + """Create an approval group for a change. + + Args: + change_id: The ID of the change + name: Name of the approval group + approver_ids: List of agent IDs who can approve + approval_type: 'everyone' or 'any' (default: 'everyone') + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_groups" + headers = get_auth_headers() + data = { + "name": name, + "approver_ids": approver_ids, + "approval_type": approval_type + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#UPDATE CHANGE APPROVAL GROUP +@mcp.tool() +async def update_change_approval_group( + change_id: int, + group_id: int, + name: Optional[str] = None, + approver_ids: Optional[List[int]] = None, + approval_type: Optional[str] = None +) -> Dict[str, Any]: + """Update a change approval group.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_groups/{group_id}" + headers = get_auth_headers() + + data = {} + if name is not None: + data["name"] = name + if approver_ids is not None: + data["approver_ids"] = approver_ids + if approval_type is not None: + data["approval_type"] = approval_type + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#CANCEL CHANGE APPROVAL GROUP +@mcp.tool() +async def cancel_change_approval_group(change_id: int, group_id: int) -> Dict[str, Any]: + """Cancel a change approval group.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_groups/{group_id}/cancel" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers) + response.raise_for_status() + return {"success": True, "message": "Approval group cancelled successfully"} + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#UPDATE APPROVAL CHAIN RULE FOR CHANGE +@mcp.tool() +async def update_approval_chain_rule_change( + change_id: int, + approval_chain_type: str = "parallel" +) -> Dict[str, Any]: + """Update approval chain rule for a change. + + Args: + change_id: The ID of the change + approval_chain_type: Type of approval chain ('parallel' or 'sequential') + """ + if approval_chain_type not in ["parallel", "sequential"]: + return {"error": "approval_chain_type must be 'parallel' or 'sequential'"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_chain" + headers = get_auth_headers() + data = {"approval_chain_type": approval_chain_type} + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#LIST CHANGE APPROVAL GROUPS +@mcp.tool() +async def list_change_approval_groups(change_id: int) -> Dict[str, Any]: + """List all approval groups within a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approval_groups" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#VIEW CHANGE APPROVAL +@mcp.tool() +async def view_change_approval(change_id: int, approval_id: int) -> Dict[str, Any]: + """View a specific change approval.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approvals/{approval_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#LIST CHANGE APPROVALS +@mcp.tool() +async def list_change_approvals(change_id: int) -> Dict[str, Any]: + """List all change approvals.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approvals" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#SEND CHANGE APPROVAL REMINDER +@mcp.tool() +async def send_change_approval_reminder(change_id: int, approval_id: int) -> Dict[str, Any]: + """Send reminder for a change approval.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approvals/{approval_id}/resend_approval" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers) + response.raise_for_status() + return {"success": True, "message": "Reminder sent successfully"} + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#CANCEL CHANGE APPROVAL +@mcp.tool() +async def cancel_change_approval(change_id: int, approval_id: int) -> Dict[str, Any]: + """Cancel a change approval.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/approvals/{approval_id}/cancel" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers) + response.raise_for_status() + return {"success": True, "message": "Approval cancelled successfully"} + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +# CHANGES NOTES ENDPOINTS + +#VIEW CHANGE NOTE +@mcp.tool() +async def view_change_note(change_id: int, note_id: int) -> Dict[str, Any]: + """View a specific note for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes/{note_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#LIST CHANGE NOTES +@mcp.tool() +async def list_change_notes(change_id: int) -> Dict[str, Any]: + """List all notes for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#UPDATE CHANGE NOTE +@mcp.tool() +async def update_change_note(change_id: int, note_id: int, body: str) -> Dict[str, Any]: + """Update a note for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes/{note_id}" + headers = get_auth_headers() + data = {"body": body} + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#DELETE CHANGE NOTE +@mcp.tool() +async def delete_change_note(change_id: int, note_id: int) -> Dict[str, Any]: + """Delete a note for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes/{note_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.delete(url, headers=headers) + if response.status_code == 204: + return {"success": True, "message": "Note deleted successfully"} + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +# CHANGES TASKS ENDPOINTS + +#CREATE CHANGE TASK +@mcp.tool() +async def create_change_task( + change_id: int, + title: str, + description: str, + status: int = 1, + priority: int = 1, + assigned_to_id: Optional[int] = None, + group_id: Optional[int] = None, + due_date: Optional[str] = None +) -> Dict[str, Any]: + """Create a task for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks" + headers = get_auth_headers() + + data = { + "title": title, + "description": description, + "status": status, + "priority": priority + } + + if assigned_to_id: + data["assigned_to_id"] = assigned_to_id + if group_id: + data["group_id"] = group_id + if due_date: + data["due_date"] = due_date + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#VIEW CHANGE TASK +@mcp.tool() +async def view_change_task(change_id: int, task_id: int) -> Dict[str, Any]: + """View a specific task for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks/{task_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#UPDATE CHANGE TASK +@mcp.tool() +async def update_change_task( + change_id: int, + task_id: int, + task_fields: Dict[str, Any] +) -> Dict[str, Any]: + """Update a task for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks/{task_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=task_fields) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#DELETE CHANGE TASK +@mcp.tool() +async def delete_change_task(change_id: int, task_id: int) -> Dict[str, Any]: + """Delete a task for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks/{task_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.delete(url, headers=headers) + if response.status_code == 204: + return {"success": True, "message": "Task deleted successfully"} + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +# CHANGES TIME ENTRIES ENDPOINTS + +#CREATE CHANGE TIME ENTRY +@mcp.tool() +async def create_change_time_entry( + change_id: int, + time_spent: str, + note: str, + agent_id: int, + executed_at: Optional[str] = None +) -> Dict[str, Any]: + """Create a time entry for a change. + + Args: + change_id: The ID of the change + time_spent: Time spent in format "hh:mm" (e.g., "02:30") + note: Description of the work done + agent_id: ID of the agent who performed the work + executed_at: When the work was done (ISO format) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries" + headers = get_auth_headers() + + data = { + "time_spent": time_spent, + "note": note, + "agent_id": agent_id + } + + if executed_at: + data["executed_at"] = executed_at + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#VIEW CHANGE TIME ENTRY +@mcp.tool() +async def view_change_time_entry(change_id: int, time_entry_id: int) -> Dict[str, Any]: + """View a specific time entry for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries/{time_entry_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#LIST CHANGE TIME ENTRIES +@mcp.tool() +async def list_change_time_entries(change_id: int) -> Dict[str, Any]: + """List all time entries for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#UPDATE CHANGE TIME ENTRY +@mcp.tool() +async def update_change_time_entry( + change_id: int, + time_entry_id: int, + time_spent: Optional[str] = None, + note: Optional[str] = None +) -> Dict[str, Any]: + """Update a time entry for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries/{time_entry_id}" + headers = get_auth_headers() + + data = {} + if time_spent is not None: + data["time_spent"] = time_spent + if note is not None: + data["note"] = note + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#DELETE CHANGE TIME ENTRY +@mcp.tool() +async def delete_change_time_entry(change_id: int, time_entry_id: int) -> Dict[str, Any]: + """Delete a time entry for a change.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/time_entries/{time_entry_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.delete(url, headers=headers) + if response.status_code == 204: + return {"success": True, "message": "Time entry deleted successfully"} + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +# OTHER CHANGES ENDPOINTS + +#MOVE CHANGE +@mcp.tool() +async def move_change(change_id: int, workspace_id: int) -> Dict[str, Any]: + """Move a change to another workspace.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/move_workspace" + headers = get_auth_headers() + data = {"workspace_id": workspace_id} + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#LIST CHANGE FIELDS +@mcp.tool() +async def list_change_fields() -> Dict[str, Any]: + """List all change fields.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/change_form_fields" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + +#GET SERVICE ITEMS +@mcp.tool() +async def list_service_items(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: + """Get list of service items from Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/service_catalog/items" + + if page < 1: + return {"error": "Page number must be greater than 0"} + if per_page < 1 or per_page > 100: + return {"error": "Page size must be between 1 and 100"} + + headers = get_auth_headers() + all_items: List[Dict[str, Any]] = [] + current_page = page + + async with httpx.AsyncClient() as client: + while True: + params = { + "page": current_page, + "per_page": per_page + } + + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + all_items.append(data) # Store the entire response for each page + + link_header = response.headers.get("Link", "") + pagination_info = parse_link_header(link_header) + + if not pagination_info.get("next"): + break + + current_page = pagination_info["next"] + + except httpx.HTTPStatusError as e: + return {"error": f"HTTP error occurred: {str(e)}"} + except Exception as e: + return {"error": f"Unexpected error: {str(e)}"} + + return { + "success": True, + "items": all_items, + "pagination": { + "starting_page": page, + "per_page": per_page, + "last_fetched_page": current_page + } + } + +#GET REQUESTED ITEMS +@mcp.tool() +async def get_requested_items(ticket_id: int) -> dict: + """Fetch requested items for a specific ticket if the ticket is a service request.""" + + async def get_ticket(ticket_id: int) -> dict: + """Fetch ticket details by ticket ID to check the ticket type.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + ticket_data = response.json() + + # Check if the ticket type is a service request + if ticket_data.get("ticket", {}).get("type") != "Service Request": + return {"success": False, "error": "Requested items can only be fetched for service requests"} + + # If ticket is a service request, proceed to fetch the requested items + return {"success": True, "ticket_type": "Service Request"} + + except httpx.HTTPStatusError as e: + return {"success": False, "error": f"HTTP error occurred: {str(e)}"} + except Exception as e: + return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + + # Step 1: Check if the ticket is a service request + ticket_check = await get_ticket(ticket_id) + + if not ticket_check.get("success", False): + return ticket_check # If ticket fetching or type check failed, return the error message + + # Step 2: If the ticket is a service request, fetch the requested items + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/requested_items" + headers = get_auth_headers() # Use your existing method to get the headers + + async with httpx.AsyncClient() as client: + try: + # Send GET request to fetch requested items + response = await client.get(url, headers=headers) + response.raise_for_status() # Will raise HTTPError for bad responses + + # If the response contains requested items, return them + if response.status_code == 200: + return response.json() + + except httpx.HTTPStatusError as e: + # If a 400 error occurs, return a message saying no service items exist + if e.response.status_code == 400: + return {"success": False, "error": "There are no service items for this ticket"} + return {"success": False, "error": f"HTTP error occurred: {str(e)}"} + except Exception as e: + return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + +#CREATE SERVICE REQUEST +@mcp.tool() +async def create_service_request( + display_id: int, + email: str, + requested_for: Optional[str] = None, + quantity: int = 1 +) -> dict: + """Create a service request in Freshservice.""" + if not isinstance(quantity, int) or quantity <= 0: + return {"success": False, "error": "Quantity must be a positive integer."} + + if requested_for and "@" not in requested_for: + return {"success": False, "error": "requested_for must be a valid email address."} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/service_catalog/items/{display_id}/place_request" + + payload = { + "email": email, + "quantity": quantity + } + + if requested_for: + payload["requested_for"] = requested_for + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + error_message = f"Failed to place request: {str(e)}" + try: + error_details = e.response.json() + return {"success": False, "error": error_details} + except Exception: + return {"success": False, "error": error_message} + except Exception as e: + return {"success": False, "error": str(e)} + +#SEND TICKET REPLY +@mcp.tool() +async def send_ticket_reply( + ticket_id: int, + body: str, + from_email: Optional[str] = None, + user_id: Optional[int] = None, + cc_emails: Optional[Union[str, List[str]]] = None, + bcc_emails: Optional[Union[str, List[str]]] = None +) -> dict: + """ + Send reply to a ticket in Freshservice.""" + + # Validation + if not ticket_id or not isinstance(ticket_id, int) or ticket_id < 1: + return {"success": False, "error": "Invalid ticket_id: Must be an integer >= 1"} + + if not body or not isinstance(body, str) or not body.strip(): + return {"success": False, "error": "Missing or empty body: Reply content is required"} + + def parse_emails(value): + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return [] # Invalid JSON format + return value or [] + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/reply" + + payload = { + "body": body.strip(), + "from_email": from_email or f"helpdesk@{FRESHSERVICE_DOMAIN}", + } + + if user_id is not None: + payload["user_id"] = user_id + + parsed_cc = parse_emails(cc_emails) + if parsed_cc: + payload["cc_emails"] = parsed_cc + + parsed_bcc = parse_emails(bcc_emails) + if parsed_bcc: + payload["bcc_emails"] = parsed_bcc + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, json=payload, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + return {"success": False, "error": f"HTTP error occurred: {str(e)}"} + except Exception as e: + return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + +#CREATE A Note +@mcp.tool() +async def create_ticket_note(ticket_id: int,body: str)-> Dict[str, Any]: + """Create a note for a ticket in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/notes" + headers = get_auth_headers() + data = { + "body": body + } + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=data) + return response.json() + + #UPDATE A CONVERSATION + +#UPDATE TICKET CONVERSATION +@mcp.tool() +async def update_ticket_conversation(conversation_id: int,body: str)-> Dict[str, Any]: + """Update a conversation for a ticket in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/conversations/{conversation_id}" + headers = get_auth_headers() + data = { + "body": body + } + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers, json=data) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot update conversation ${response.json()}" + +#GET ALL TICKET CONVERSATION +@mcp.tool() +async def list_all_ticket_conversation(ticket_id: int)-> Dict[str, Any]: + """List all conversation of a ticket in freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/conversations" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch ticket conversations ${response.json()}" + +#GET ALL PRODUCTS +@mcp.tool() +async def get_all_products(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: + """List all the products from Freshservice.""" + if page < 1: + return {"error": "Page number must be greater than 0"} + + if per_page < 1 or per_page > 100: + return {"error": "Page size must be between 1 and 100"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products" + headers = get_auth_headers() + + params = { + "page": page, + "per_page": per_page + } + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + products = data.get("products", []) + + link_header = response.headers.get("Link", "") + pagination_info = parse_link_header(link_header) + next_page = pagination_info.get("next") + + return { + "success": True, + "products": products, + "pagination": { + "current_page": page, + "next_page": next_page, + "has_next": bool(next_page), + "per_page": per_page + } + } + + except httpx.HTTPStatusError as e: + return {"success": False, "error": f"HTTP error occurred: {str(e)}"} + except Exception as e: + return {"success": False, "error": f"Unexpected error occurred: {str(e)}"} + +#GET PRODUCT BY ID +@mcp.tool() +async def get_products_by_id(product_id:int)-> Dict[str, Any]: + """Get product by product ID in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products/{product_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch products from the freshservice ${response.json()}" + +#CREATE PRODUCT +@mcp.tool() +async def create_product( + name: str, + asset_type_id: int, + manufacturer: Optional[str] = None, + status: Optional[Union[str, int]] = None, + mode_of_procurement: Optional[str] = None, + depreciation_type_id: Optional[int] = None, + description: Optional[str] = None, + description_text: Optional[str] = None +) -> Dict[str, Any]: + """Create a product in Freshservice.""" + + # Allowed statuses mapping + allowed_statuses = { + "In Production": "In Production", + "In Pipeline": "In Pipeline", + "Retired": "Retired", + 1: "In Production", + 2: "In Pipeline", + 3: "Retired" + } + + # Validate status + if status is not None: + if status not in allowed_statuses: + return { + "success": False, + "error": ( + "Invalid 'status'. It should be one of: " + "[\"In Production\", 1], [\"In Pipeline\", 2], [\"Retired\", 3]" + ) + } + status = allowed_statuses[status] + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products" + headers = get_auth_headers() + + payload = { + "name": name, + "asset_type_id": asset_type_id + } + + if manufacturer: + payload["manufacturer"] = manufacturer + if status: + payload["status"] = status + if mode_of_procurement: + payload["mode_of_procurement"] = mode_of_procurement + if depreciation_type_id: + payload["depreciation_type_id"] = depreciation_type_id + if description: + payload["description"] = description + if description_text: + payload["description_text"] = description_text + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + return {"success": True, "data": response.json()} + except httpx.HTTPStatusError as http_err: + return { + "success": False, + "status_code": response.status_code, + "error": f"HTTP error occurred: {http_err}", + "response": response.json() + } + except Exception as err: + return { + "success": False, + "error": f"An unexpected error occurred: {err}" + } + +#UPDATE PRODUCT +@mcp.tool() +async def update_product( + id: int, + name: str, + asset_type_id: int, + manufacturer: Optional[str] = None, + status: Optional[Union[str, int]] = None, + mode_of_procurement: Optional[str] = None, + depreciation_type_id: Optional[int] = None, + description: Optional[str] = None, + description_text: Optional[str] = None +) -> Dict[str, Any]: + """Update a product in Freshservice.""" + + allowed_statuses = { + "In Production": "In Production", + "In Pipeline": "In Pipeline", + "Retired": "Retired", + 1: "In Production", + 2: "In Pipeline", + 3: "Retired" + } + + if status is not None: + if status not in allowed_statuses: + return { + "success": False, + "error": ( + "Invalid 'status'. It should be one of: " + "[\"In Production\", 1], [\"In Pipeline\", 2], [\"Retired\", 3]" + ) + } + status = allowed_statuses[status] + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products/{id}" + headers = get_auth_headers() + + payload = { + "name": name, + "asset_type_id": asset_type_id + } + + # Optional updates + if manufacturer: + payload["manufacturer"] = manufacturer + if status: + payload["status"] = status + if mode_of_procurement: + payload["mode_of_procurement"] = mode_of_procurement + if depreciation_type_id: + payload["depreciation_type_id"] = depreciation_type_id + if description: + payload["description"] = description + if description_text: + payload["description_text"] = description_text + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=payload) + response.raise_for_status() + return {"success": True, "data": response.json()} + except httpx.HTTPStatusError as http_err: + return { + "success": False, + "status_code": response.status_code, + "error": f"HTTP error occurred: {http_err}", + "response": response.json() + } + except Exception as err: + return { + "success": False, + "error": f"Unexpected error occurred: {err}" + } + +#CREATE REQUESTER +@mcp.tool() +async def create_requester( + first_name: str, + last_name: Optional[str] = None, + job_title: Optional[str] = None, + primary_email: Optional[str] = None, + secondary_emails: Optional[List[str]] = None, + work_phone_number: Optional[str] = None, + mobile_phone_number: Optional[str] = None, + department_ids: Optional[List[int]] = None, + can_see_all_tickets_from_associated_departments: Optional[bool] = None, + reporting_manager_id: Optional[int] = None, + address: Optional[str] = None, + time_zone: Optional[str] = None, + time_format: Optional[str] = None, # "12h" or "24h" + language: Optional[str] = None, + location_id: Optional[int] = None, + background_information: Optional[str] = None, + custom_fields: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """Creates a requester in Freshservice.""" + + if not isinstance(first_name, str) or not first_name.strip(): + return {"success": False, "error": "'first_name' must be a non-empty string."} + + if not (primary_email or work_phone_number or mobile_phone_number): + return { + "success": False, + "error": "At least one of 'primary_email', 'work_phone_number', or 'mobile_phone_number' is required." + } + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters" + headers = get_auth_headers() + + payload: Dict[str, Any] = { + "first_name": first_name.strip() + } + + # Add optional fields if provided + optional_fields = { + "last_name": last_name, + "job_title": job_title, + "primary_email": primary_email, + "secondary_emails": secondary_emails, + "work_phone_number": work_phone_number, + "mobile_phone_number": mobile_phone_number, + "department_ids": department_ids, + "can_see_all_tickets_from_associated_departments": can_see_all_tickets_from_associated_departments, + "reporting_manager_id": reporting_manager_id, + "address": address, + "time_zone": time_zone, + "time_format": time_format, + "language": language, + "location_id": location_id, + "background_information": background_information, + "custom_fields": custom_fields + } + + payload.update({k: v for k, v in optional_fields.items() if v is not None}) + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + return {"success": True, "data": response.json()} + + except httpx.HTTPStatusError as http_err: + return { + "success": False, + "status_code": response.status_code, + "error": f"HTTP error: {http_err}", + "response": response.json() + } + except Exception as err: + return { + "success": False, + "error": f"Unexpected error: {err}" + } + +#GET ALL REQUESTER +@mcp.tool() +async def get_all_requesters(page: int = 1, per_page: int = 30) -> Dict[str, Any]: + """Fetch all requesters from Freshservice.""" + if page < 1: + return {"success": False, "error": "Page number must be greater than 0"} + + if per_page < 1 or per_page > 100: + return {"success": False, "error": "Page size must be between 1 and 100"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters" + headers = get_auth_headers() + params = {"page": page, "per_page": per_page} + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + requesters = data.get("requesters", []) + + link_header = response.headers.get("Link", "") + pagination_info = parse_link_header(link_header) + + return { + "success": True, + "requesters": requesters, + "pagination": { + "current_page": page, + "per_page": per_page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "has_more": pagination_info.get("next") is not None + } + } + except httpx.HTTPStatusError as e: + return {"success": False, "error": f"HTTP error: {str(e)}"} + except Exception as e: + return {"success": False, "error": f"Unexpected error: {str(e)}"} + +#GET REQUESTERS BY ID +@mcp.tool() +async def get_requester_id(requester_id:int)-> Dict[str, Any]: + """Get requester by ID in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters/{requester_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch requester from the freshservice ${response.json()}" + +#LIST ALL REQUESTER FIELDS +@mcp.tool() +async def list_all_requester_fields()-> Dict[str, Any]: + """List all requester fields in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_fields" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch requester from the freshservice ${response.json()}" + +#UPDATE REQUESTER +@mcp.tool() +async def update_requester( + requester_id: int, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + job_title: Optional[str] = None, + primary_email: Optional[str] = None, + secondary_emails: Optional[List[str]] = None, + work_phone_number: Optional[int] = None, + mobile_phone_number: Optional[int] = None, + department_ids: Optional[List[int]] = None, + can_see_all_tickets_from_associated_departments: Optional[bool] = False, + reporting_manager_id: Optional[int] = None, + address: Optional[str] = None, + time_zone: Optional[str] = None, + time_format: Optional[str] = None, + language: Optional[str] = None, + location_id: Optional[int] = None, + background_information: Optional[str] = None, + custom_fields: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """Update a requester in Freshservice.""" + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters/{requester_id}" + headers = get_auth_headers() + + payload = { + "first_name": first_name, + "last_name": last_name, + "job_title": job_title, + "primary_email": primary_email, + "secondary_emails": secondary_emails, + "work_phone_number": work_phone_number, + "mobile_phone_number": mobile_phone_number, + "department_ids": department_ids, + "can_see_all_tickets_from_associated_departments": can_see_all_tickets_from_associated_departments, + "reporting_manager_id": reporting_manager_id, + "address": address, + "time_zone": time_zone, + "time_format": time_format, + "language": language, + "location_id": location_id, + "background_information": background_information, + "custom_fields": custom_fields + } + + data = {k: v for k, v in payload.items() if v is not None} + + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers, json=data) + if response.status_code == 200: + return response.json() + else: + return {"success": False, "error": response.text, "status_code": response.status_code} + +#FILTER REQUESTERS +@mcp.tool() +async def filter_requesters(query: str,include_agents: bool = False) -> Dict[str, Any]: + """Filter requesters in Freshservice.""" + encoded_query = urllib.parse.quote(query) + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters?query={encoded_query}" + + if include_agents: + url += "&include_agents=true" + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + if response.status_code == 200: + return response.json() + else: + return { + "error": f"Failed to filter requesters: {response.status_code}", + "details": response.text + } + +#CREATE AN AGENT +@mcp.tool() +async def create_agent( + first_name: str, + email: str = None, + last_name: Optional[str] = None, + occasional: Optional[bool] = False, + job_title: Optional[str] = None, + work_phone_number: Optional[int] = None, + mobile_phone_number: Optional[int] = None, +) -> Dict[str, Any]: + """Create a new agent in Freshservice.""" + + data = AgentInput( + first_name=first_name, + last_name=last_name, + occasional=occasional, + job_title=job_title, + email=email, + work_phone_number=work_phone_number, + mobile_phone_number=mobile_phone_number + ).dict(exclude_none=True) + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=data) + if response.status_code == 200 or response.status_code == 201: + return response.json() + else: + return { + "error": f"Failed to create agent", + "status_code": response.status_code, + "details": response.json() + } + +#GET AN AGENT +@mcp.tool() +async def get_agent(agent_id:int)-> Dict[str, Any]: + """Get agent by id in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents/{agent_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch requester from the freshservice ${response.json()}" + +#GET ALL AGENTS +@mcp.tool() +async def get_all_agents(page: int = 1, per_page: int = 30) -> Dict[str, Any]: + """Fetch agents from Freshservice.""" + if page < 1: + return {"success": False, "error": "Page number must be greater than 0"} + + if per_page < 1 or per_page > 100: + return {"success": False, "error": "Page size must be between 1 and 100"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents" + headers = get_auth_headers() + params = {"page": page, "per_page": per_page} + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + agents = data.get("agents", []) + + # Parse pagination info from Link header + link_header = response.headers.get("Link", "") + pagination_info = parse_link_header(link_header) + + return { + "success": True, + "agents": agents, + "pagination": { + "current_page": page, + "per_page": per_page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "has_more": pagination_info.get("next") is not None + } + } + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to get all agents: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + +#FILTER AGENTS +@mcp.tool() +async def filter_agents(query: str) -> List[Dict[str, Any]]: + """Filter Freshservice agents based on a query.""" + base_url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents" + headers = get_auth_headers() + all_agents = [] + page = 1 + # Freshservice API requires the query to be wrapped in double quotes + encoded_query = urllib.parse.quote(f'"{query}"') + + async with httpx.AsyncClient() as client: + while True: + url = f"{base_url}?query={encoded_query}&page={page}" + response = await client.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + all_agents.extend(data.get("agents", [])) + + link_header = response.headers.get("link") + pagination = parse_link_header(link_header) + + if not pagination.get("next"): + break + page = pagination["next"] + + return all_agents + +#UPDATE AGENT +@mcp.tool() +async def update_agent(agent_id, occasional=None, email=None, department_ids=None, + can_see_all_tickets_from_associated_departments=None, reporting_manager_id=None, + address=None, time_zone=None, time_format=None, language=None, + location_id=None, background_information=None, scoreboard_level_id=None): + """Update the agent details in the Freshservice.""" + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents/{agent_id}" + headers = get_auth_headers() + + payload = { + "occasional": occasional, + "email": email, + "department_ids": department_ids, + "can_see_all_tickets_from_associated_departments": can_see_all_tickets_from_associated_departments, + "reporting_manager_id": reporting_manager_id, + "address": address, + "time_zone": time_zone, + "time_format": time_format, + "language": language, + "location_id": location_id, + "background_information": background_information, + "scoreboard_level_id": scoreboard_level_id + } + + payload = {k: v for k, v in payload.items() if v is not None} + + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers,json=payload) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch agents from the freshservice ${response.json()}" + +#GET AGENT FIELDS +@mcp.tool() +async def get_agent_fields()-> Dict[str, Any]: + """Get all agent fields in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agent_fields" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch agents from the freshservice ${response.json()}" + +#GET ALL AGENT GROUPS +@mcp.tool() +async def get_all_agent_groups()-> Dict[str, Any]: + """Get all agent groups in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch agents from the freshservice ${response.json()}" + +#GET AGENT GROUP BY ID +@mcp.tool() +async def getAgentGroupById(group_id:int)-> Dict[str, Any]: + """Get agent groups by its group id in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups/{group_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch agents from the freshservice ${response.json()}" + +#ADD REQUESTER TO GROUP +@mcp.tool() +async def add_requester_to_group( + group_id: int, + requester_id: int +) -> Dict[str, Any]: + """Add a requester to a manual requester group in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups/{group_id}/members/{requester_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers) + response.raise_for_status() + + return {"success": f"Requester {requester_id} added to group {group_id}."} + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to add requester to group: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + +#CREATE GROUP +@mcp.tool() +async def create_group(group_data: Dict[str, Any]) -> Dict[str, Any]: + """Create a group in Freshservice.""" + if "name" not in group_data: + return {"error": "Field 'name' is required to create a group."} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=group_data) + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to create group: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + +#UPDATE GROUP +@mcp.tool() +async def update_group(group_id: int, group_fields: Dict[str, Any]) -> Dict[str, Any]: + """Update a group in Freshservice.""" + try: + validated_fields = GroupCreate(**group_fields) + group_data = validated_fields.model_dump(exclude_none=True) + except Exception as e: + return {"error": f"Validation error: {str(e)}"} + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups/{group_id}" + headers = get_auth_headers() + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=group_data) + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to update group: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + +#GET ALL REQUETER GROUPS +@mcp.tool() +async def get_all_requester_groups(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: + """Get all requester groups in Freshservice.""" + if page < 1: + return {"error": "Page number must be greater than 0"} + + if per_page < 1 or per_page > 100: + return {"error": "Page size must be between 1 and 100"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups" + headers = get_auth_headers() + + params = { + "page": page, + "per_page": per_page + } + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + # Parse the Link header for pagination info + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + data = response.json() + + return { + "success": True, + "requester_groups": data, + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + + except httpx.HTTPStatusError as e: + return {"error": f"Failed to fetch all requester groups: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + +#GET REQUETER GROUPS BY ID +@mcp.tool() +async def get_requester_groups_by_id(requester_group_id:int)-> Dict[str, Any]: + """Get requester groups in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups/{requester_group_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + status_code = response.status_code + if status_code == 200: + return response.json() + else: + return f"Cannot fetch requester group from the freshservice ${response.json()}" + +#CREATE REQUESTER GROUP +@mcp.tool() +async def create_requester_group( + name: str, + description: Optional[str] = None +) -> Dict[str, Any]: + """Create a requester group in Freshservice.""" + group_data = {"name": name} + if description: + group_data["description"] = description + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=group_data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to create requester group: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#UPDATE REQUESTER GROUP +@mcp.tool() +async def update_requester_group(id: int,name: Optional[str] = None,description: Optional[str] = None) -> Dict[str, Any]: + """Update an requester group in Freshservice.""" + group_data = {} + if name: + group_data["name"] = name + if description: + group_data["description"] = description + + if not group_data: + return {"error": "At least one field (name or description) must be provided to update."} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups/{id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=group_data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to update requester group: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + +#GET LIST OF REQUESTER GROUP MEMBERS +@mcp.tool() +async def list_requester_group_members( + group_id: int +) -> Dict[str, Any]: + """List all members of a requester group in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups/{group_id}/members" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to fetch list of requester group memebers: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET ALL CANNED RESPONSES +@mcp.tool() +async def get_all_canned_response() -> Dict[str, Any]: + """List all canned response in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_responses" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() # Will raise an exception for 4xx/5xx responses + + # Return the response JSON (list of members) + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to get all canned response folder: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET CANNED RESPONSE BY ID +@mcp.tool() +async def get_canned_response( + id: int +) -> Dict[str, Any]: + """Get a canned response in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_responses/{id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() # Will raise HTTPStatusError for 4xx/5xx responses + + # Only parse JSON if the response is not empty + if response.content: + return response.json() + else: + return {"error": "No content returned for the requested canned response."} + + except httpx.HTTPStatusError as e: + # Handle specific HTTP errors like 404, 403, etc. + if e.response.status_code == 404: + return {"error": "Canned response not found (404)"} + else: + return { + "error": f"Failed to retrieve canned response: {str(e)}", + "details": e.response.json() if e.response else None + } + + except Exception as e: + return {"error": f"Unexpected error: {str(e)}"} + +#LIST ALL CANNED RESPONSE FOLDER +@mcp.tool() +async def list_all_canned_response_folder() -> Dict[str, Any]: + """List all canned response of a folder in Freshservice.""" + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_response_folders" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to list all canned response folder: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#LIST CANNED RESPONSE FOLDER +@mcp.tool() +async def list_canned_response_folder( + id: int +) -> Dict[str, Any]: + """List canned response folder in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_response_folders/{id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to list canned response folder: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET ALL WORKSPACES +@mcp.tool() +async def list_all_workspaces() -> Dict[str, Any]: + """List all workspaces in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/workspaces" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to fetch list of solution workspaces: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET WORKSPACE +@mcp.tool() +async def get_workspace(id: int) -> Dict[str, Any]: + """Get a workspace by its ID in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/workspaces/{id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to fetch workspace: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET ALL SOLUTION CATEGORY +@mcp.tool() +async def get_all_solution_category() -> Dict[str, Any]: + """Get all solution category in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to get all solution category: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET SOLUTION CATEGORY +@mcp.tool() +async def get_solution_category(id: int) -> Dict[str, Any]: + """Get solution category by its ID in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories/{id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to get solution category: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#CREATE SOLUTION CATEGORY +@mcp.tool() +async def create_solution_category( + name: str, + description: str = None, + workspace_id: int = None, +) -> Dict[str, Any]: + """Create a new solution category in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories" + headers = get_auth_headers() + + category_data = { + "name": name, + "description": description, + "workspace_id": workspace_id, + } + + category_data = {key: value for key, value in category_data.items() if value is not None} + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=category_data) + response.raise_for_status() + + return response.json() + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to create solution category: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#UPDATE SOLUTION CATEGORY +@mcp.tool() +async def update_solution_category( + category_id: int, + name: str, + description: str = None, + workspace_id: int = None, + default_category: bool = None, +) -> Dict[str, Any]: + """Update a solution category in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories/{category_id}" + headers = get_auth_headers() + + + category_data = { + "name": name, + "description": description, + "workspace_id": workspace_id, + "default_category": default_category, + } + + + category_data = {key: value for key, value in category_data.items() if value is not None} + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=category_data) + response.raise_for_status() + + return response.json() + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to update solution category: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET LIST OF SOLUTION FOLDER +@mcp.tool() +async def get_list_of_solution_folder(id:int) -> Dict[str, Any]: + """Get list of solution folder by its ID in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders?category_id={id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to fetch list of solution folder: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET SOLUTION FOLDER +@mcp.tool() +async def get_solution_folder(id: int) -> Dict[str, Any]: + """Get solution folder by its ID in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders/{id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to fetch solution folder: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET LIST OF SOLUTION ARTICLE +@mcp.tool() +async def get_list_of_solution_article(id:int) -> Dict[str, Any]: + """Get list of solution article in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles?folder_id={id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to fetch list of solution article: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#GET SOLUTION ARTICLE +@mcp.tool() +async def get_solution_article(id:int) -> Dict[str, Any]: + """Get solution article by id in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to fetch solution article: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#CREATE SOLUTION ARTICLE +@mcp.tool() +async def create_solution_article( + title: str, + description: str, + folder_id: int, + article_type: Optional[int] = 1, # 1 - permanent, 2 - workaround + status: Optional[int] = 1, # 1 - draft, 2 - published + tags: Optional[List[str]] = None, + keywords: Optional[List[str]] = None, + review_date: Optional[str] = None # Format: YYYY-MM-DD +) -> Dict[str, Any]: + """Create a new solution article in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles" + headers = get_auth_headers() + + article_data = { + "title": title, + "description": description, + "folder_id": folder_id, + "article_type": article_type, + "status": status, + "tags": tags, + "keywords": keywords, + "review_date": review_date + } + + article_data = {key: value for key, value in article_data.items() if value is not None} + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=article_data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to create solution article: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#UPDATE SOLUTION ARTICLE +@mcp.tool() +async def update_solution_article( + article_id: int, + title: Optional[str] = None, + description: Optional[str] = None, + folder_id: Optional[int] = None, + article_type: Optional[int] = None, # 1 - permanent, 2 - workaround + status: Optional[int] = None, # 1 - draft, 2 - published + tags: Optional[List[str]] = None, + keywords: Optional[List[str]] = None, + review_date: Optional[str] = None # Format: YYYY-MM-DD +) -> Dict[str, Any]: + """Update a solution article in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{article_id}" + headers = get_auth_headers() + + update_data = { + "title": title, + "description": description, + "folder_id": folder_id, + "article_type": article_type, + "status": status, + "tags": tags, + "keywords": keywords, + "review_date": review_date + } + + update_data = {key: value for key, value in update_data.items() if value is not None} + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=update_data) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to update solution article: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#CREATE SOLUTION FOLDER +@mcp.tool() +async def create_solution_folder( + name: str, + category_id: int, + department_ids: List[int], + visibility: int = 4, + description: Optional[str] = None +) -> Dict[str, Any]: + """Create a new folder under a solution category in Freshservice.""" + + if not department_ids: + return {"error": "department_ids must be provided and cannot be empty."} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders" + headers = get_auth_headers() + + payload = { + "name": name, + "category_id": category_id, + "visibility": visibility, # Allowed values: 1, 2, 3, 4, 5, 6, 7 + "description": description, + "department_ids": department_ids + } + + payload = {k: v for k, v in payload.items() if v is not None} + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=payload) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to create solution folder: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#UPDATE SOLUTION FOLDER +@mcp.tool() +async def update_solution_folder( + id: int, + name: Optional[str] = None, + description: Optional[str] = None, + visibility: Optional[int] = None # Allowed values: 1, 2, 3, 4, 5, 6, 7 +) -> Dict[str, Any]: + """Update an existing solution folder's details in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders/{id}" + headers = get_auth_headers() + + payload = { + "name": name, + "description": description, + "visibility": visibility + } + + payload = {k: v for k, v in payload.items() if v is not None} + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=payload) + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to update solution folder: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + +#PUBLISH SOLUTION ARTICLE +@mcp.tool() +async def publish_solution_article(article_id: int) -> Dict[str, Any]: + """Publish a solution article in Freshservice.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{article_id}" + headers = get_auth_headers() + + payload = {"status": 2} + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers,json=payload) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + error_text = None + try: + error_text = e.response.json() if e.response else None + except Exception: + error_text = e.response.text if e.response else None + + return { + "error": f"Failed to publish solution article: {str(e)}", + "status_code": e.response.status_code if e.response else None, + "details": error_text + } + + except Exception as e: + return { + "error": f"Unexpected error occurred: {str(e)}" + } + + +# ============================================================================ +# ASSETS / CMDB MANAGEMENT +# ============================================================================ + +#GET ASSETS +@mcp.tool() +async def get_assets( + page: Optional[int] = 1, + per_page: Optional[int] = 30, + include: Optional[str] = None, + order_by: Optional[str] = None, + order_type: Optional[str] = None, + trashed: Optional[bool] = False, + workspace_id: Optional[int] = None +) -> Dict[str, Any]: + """Get all assets from Freshservice with pagination support. + + Args: + page: Page number (default: 1) + per_page: Number of items per page (1-100, default: 30) + include: Embed additional details. Use 'type_fields' to get asset type specific fields (costs 1 extra API credit) + order_by: Sort field: 'id', 'created_at', or 'updated_at' (default: created_at) + order_type: Sort order: 'asc' or 'desc' (default: desc) + trashed: If True, return only trashed assets (default: False) + workspace_id: Filter by workspace ID. Use 0 for all workspaces. If not provided, returns assets from primary workspace only. + """ + if page < 1: + return {"error": "Page number must be greater than 0"} + + if per_page < 1 or per_page > 100: + return {"error": "Page size must be between 1 and 100"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets" + + params = { + "page": page, + "per_page": per_page + } + + if include: + params["include"] = include + if order_by: + params["order_by"] = order_by + if order_type: + params["order_type"] = order_type + if trashed: + params["trashed"] = "true" + if workspace_id is not None: + params["workspace_id"] = workspace_id + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + assets = response.json() + + return { + "assets": assets, + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET BY ID +@mcp.tool() +async def get_asset_by_id( + display_id: int, + include: Optional[str] = None +) -> Dict[str, Any]: + """Get a specific asset by its display ID in Freshservice. + + Args: + display_id: The display ID of the asset to retrieve + include: Embed additional details. Use 'type_fields' to get asset type specific fields (costs 1 extra API credit) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" + + params = {} + if include: + params["include"] = include + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#CREATE ASSET +@mcp.tool() +async def create_asset( + name: str, + asset_type_id: int, + asset_tag: Optional[str] = None, + impact: Optional[str] = "low", + usage_type: Optional[str] = "permanent", + description: Optional[str] = None, + user_id: Optional[int] = None, + location_id: Optional[int] = None, + department_id: Optional[int] = None, + agent_id: Optional[int] = None, + group_id: Optional[int] = None, + assigned_on: Optional[str] = None, + workspace_id: Optional[int] = None, + type_fields: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """Create a new asset in Freshservice. + + Args: + name: Name of the asset (MANDATORY) + asset_type_id: ID of the asset type (MANDATORY) + asset_tag: Asset tag (e.g., 'ASSET-9') + impact: Impact level: 'low', 'medium', or 'high' (default: 'low') + usage_type: Usage type: 'permanent' or 'loaner' (default: 'permanent') + description: Description of the asset + user_id: ID of the user the asset is assigned to (Used By) + location_id: ID of the associated location + department_id: ID of the associated department + agent_id: ID of the agent managing the asset (Managed By) + group_id: ID of the agent group managing the asset (Managed By Group) + assigned_on: Date when asset was assigned (ISO format) + workspace_id: Workspace ID (applicable only to accounts with workspaces) + type_fields: Asset type specific fields (e.g., product_id, vendor_id, serial_number, cost, etc.) + """ + if impact and impact not in ("low", "medium", "high"): + return {"error": "Invalid impact value. Must be 'low', 'medium', or 'high'"} + + if usage_type and usage_type not in ("permanent", "loaner"): + return {"error": "Invalid usage_type value. Must be 'permanent' or 'loaner'"} + + data: Dict[str, Any] = { + "name": name, + "asset_type_id": asset_type_id + } + + if asset_tag: + data["asset_tag"] = asset_tag + if impact: + data["impact"] = impact + if usage_type: + data["usage_type"] = usage_type + if description: + data["description"] = description + if user_id is not None: + data["user_id"] = user_id + if location_id is not None: + data["location_id"] = location_id + if department_id is not None: + data["department_id"] = department_id + if agent_id is not None: + data["agent_id"] = agent_id + if group_id is not None: + data["group_id"] = group_id + if assigned_on: + data["assigned_on"] = assigned_on + if workspace_id is not None: + data["workspace_id"] = workspace_id + if type_fields: + data["type_fields"] = type_fields + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return { + "success": True, + "message": "Asset created successfully", + "asset": response.json() + } + except httpx.HTTPStatusError as e: + error_message = f"Failed to create asset: {str(e)}" + try: + error_details = e.response.json() + if "errors" in error_details: + error_message = f"Validation errors: {error_details['errors']}" + except Exception: + pass + return {"success": False, "error": error_message} + except Exception as e: + return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + + +#UPDATE ASSET +@mcp.tool() +async def update_asset( + display_id: int, + asset_fields: Dict[str, Any] +) -> Dict[str, Any]: + """Update an existing asset in Freshservice. + + Args: + display_id: The display ID of the asset to update + asset_fields: Dictionary of fields to update. Supported fields include: + - name: Name of the asset + - asset_type_id: ID of the asset type + - asset_tag: Asset tag + - impact: 'low', 'medium', or 'high' + - usage_type: 'permanent' or 'loaner' + - description: Description + - user_id: User ID (Used By) + - location_id: Location ID + - department_id: Department ID + - agent_id: Agent ID (Managed By) + - group_id: Agent group ID (Managed By Group) + - assigned_on: Assignment date (ISO format) + - type_fields: Asset type specific fields dict + + Note: workspace_id cannot be updated here. Use move_asset instead. + """ + if not asset_fields: + return {"error": "No fields provided for update"} + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=asset_fields) + response.raise_for_status() + return { + "success": True, + "message": "Asset updated successfully", + "asset": response.json() + } + except httpx.HTTPStatusError as e: + error_message = f"Failed to update asset: {str(e)}" + try: + error_details = e.response.json() + if "errors" in error_details: + error_message = f"Validation errors: {error_details['errors']}" + except Exception: + pass + return {"success": False, "error": error_message} + except Exception as e: + return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + + +#DELETE ASSET (move to trash) +@mcp.tool() +async def delete_asset(display_id: int) -> str: + """Delete an asset in Freshservice (moves it to trash). + + Args: + display_id: The display ID of the asset to delete + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.delete(url, headers=headers) + + if response.status_code == 204: + return "Asset deleted successfully (moved to trash)" + elif response.status_code == 404: + return "Error: Asset not found" + else: + try: + response_data = response.json() + return f"Error: {response_data.get('error', 'Failed to delete asset')}" + except ValueError: + return "Error: Unexpected response format" + + +#DELETE ASSET PERMANENTLY +@mcp.tool() +async def delete_asset_permanently(display_id: int) -> str: + """Permanently delete an asset from Freshservice. This action cannot be undone. + + Args: + display_id: The display ID of the trashed asset to permanently delete + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/delete_forever" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers) + + if response.status_code == 204: + return "Asset permanently deleted" + elif response.status_code == 404: + return "Error: Asset not found" + else: + try: + response_data = response.json() + return f"Error: {response_data.get('error', 'Failed to permanently delete asset')}" + except ValueError: + return "Error: Unexpected response format" + + +#RESTORE ASSET +@mcp.tool() +async def restore_asset(display_id: int) -> str: + """Restore a previously deleted (trashed) asset in Freshservice. + + Args: + display_id: The display ID of the trashed asset to restore + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/restore" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + response = await client.put(url, headers=headers) + + if response.status_code == 204: + return "Asset restored successfully" + elif response.status_code == 404: + return "Error: Asset not found in trash" + else: + try: + response_data = response.json() + return f"Error: {response_data.get('error', 'Failed to restore asset')}" + except ValueError: + return "Error: Unexpected response format" + + +#SEARCH ASSETS +@mcp.tool() +async def search_assets( + search_query: str, + page: Optional[int] = 1, + trashed: Optional[bool] = False +) -> Dict[str, Any]: + """Search assets in Freshservice using asset attributes. + + Args: + search_query: Search query string using asset fields. + Supported fields: name, asset_tag, serial_number, mac_addresses, ip_addresses, uuid, item_id, imei_number. + Examples: "name:'dell'", "serial_number:'HSN123'", "asset_tag:'ASSET-65'" + Note: The query will be automatically URL-encoded and wrapped in double quotes. + page: Page number (default: 1). 30 results per page. + trashed: If True, search in trashed assets (default: False) + """ + encoded_search = urllib.parse.quote(f'"{search_query}"') + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets?search={encoded_search}&page={page}" + + if trashed: + url += "&trashed=true" + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + total_count = response.headers.get('X-Total-Count', None) + + result = response.json() + result["pagination"] = { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + } + if total_count: + result["total_count"] = int(total_count) + return result + + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#FILTER ASSETS +@mcp.tool() +async def filter_assets( + filter_query: str, + page: Optional[int] = 1, + include: Optional[str] = None +) -> Dict[str, Any]: + """Filter assets in Freshservice using asset attributes. + + Args: + filter_query: Filter query string using asset fields. + Supported fields: workspace_id, asset_type_id, department_id, location_id, + asset_state, user_id, agent_id, name, asset_tag, created_at, updated_at, + serial_number, mac_addresses, ip_addresses, uuid, item_id, imei_number, device42_id. + Operators: AND, OR, parentheses (), :> (>=), :< (<=) + Examples: + "asset_state:'IN USE'" + "asset_state:'IN STOCK' AND created_at:>'2023-01-01'" + "department_id:5 AND location_id:3" + "asset_type_id:25 AND agent_id:null" + Note: The query will be automatically URL-encoded and wrapped in double quotes. + page: Page number (default: 1). 30 results per page, max 40 pages. + include: Embed additional details. Use 'type_fields' for asset type specific fields. + """ + encoded_filter = urllib.parse.quote(f'"{filter_query}"') + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets?filter={encoded_filter}&page={page}" + + if include: + url += f"&include={include}" + + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + total_count = response.headers.get('X-Total-Count', None) + + result = response.json() + result["pagination"] = { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + } + if total_count: + result["total_count"] = int(total_count) + return result + + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#MOVE ASSET TO WORKSPACE +@mcp.tool() +async def move_asset( + display_id: int, + workspace_id: int, + agent_id: Optional[int] = None, + group_id: Optional[int] = None +) -> Dict[str, Any]: + """Move an asset to a different workspace in Freshservice. + + Note: This endpoint is applicable only to accounts with workspaces. + + Args: + display_id: The display ID of the asset to move + workspace_id: The target workspace ID + agent_id: Optional new agent ID for the asset + group_id: Optional new group ID for the asset + """ + data: Dict[str, Any] = {"workspace_id": workspace_id} + if agent_id is not None: + data["agent_id"] = agent_id + if group_id is not None: + data["group_id"] = group_id + + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/move_workspace" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return { + "success": True, + "message": "Asset moved successfully", + "asset": response.json() + } + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to move asset: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to move asset: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET COMPONENTS +@mcp.tool() +async def get_asset_components(display_id: int) -> Dict[str, Any]: + """Get all components of an asset in Freshservice (e.g., Processor, Memory, Disk, etc.). + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/components" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset components: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset components: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET ASSIGNMENT HISTORY +@mcp.tool() +async def get_asset_assignment_history(display_id: int) -> Dict[str, Any]: + """Get the user assignment history for a specific asset in Freshservice. + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/assignment-history" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch assignment history: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch assignment history: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET REQUESTS +@mcp.tool() +async def get_asset_requests(display_id: int) -> Dict[str, Any]: + """List all associated requests (tickets) for a specific asset in Freshservice. + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/requests" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset requests: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset requests: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET CONTRACTS +@mcp.tool() +async def get_asset_contracts(display_id: int) -> Dict[str, Any]: + """List all associated contracts for a specific asset in Freshservice. + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/contracts" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset contracts: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset contracts: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +# ============================================================================ +# ASSET RELATIONSHIPS +# ============================================================================ + +#GET ASSET RELATIONSHIPS +@mcp.tool() +async def get_asset_relationships(display_id: int) -> Dict[str, Any]: + """List all relationships for a specific asset in Freshservice. + + Args: + display_id: The display ID of the asset + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/assets/{display_id}/relationships" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset relationships: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset relationships: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ALL RELATIONSHIPS +@mcp.tool() +async def get_all_relationships(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: + """List all asset relationships in the Freshservice account. + + Args: + page: Page number (default: 1) + per_page: Number of items per page (1-100, default: 30) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships" + params = {"page": page, "per_page": per_page} + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + return { + "relationships": response.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET RELATIONSHIP BY ID +@mcp.tool() +async def get_relationship_by_id(relationship_id: int) -> Dict[str, Any]: + """View a specific relationship by its ID in Freshservice. + + Args: + relationship_id: The ID of the relationship + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships/{relationship_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch relationship: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch relationship: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#CREATE RELATIONSHIPS IN BULK +@mcp.tool() +async def create_asset_relationships( + relationships: List[Dict[str, Any]] +) -> Dict[str, Any]: + """Create asset relationships in bulk in Freshservice. + + This is an asynchronous operation performed via background jobs. + The response contains a job_id which can be used with get_job_status() to track progress. + Supported primary_type/secondary_type values: 'asset', 'requester', 'agent', 'department', 'software'. + + Args: + relationships: List of relationship objects. Each object should contain: + - relationship_type_id: ID of the relationship type (MANDATORY) + - primary_id: ID of the primary entity (MANDATORY) + - primary_type: Type of the primary entity: 'asset', 'requester', 'agent', 'department', 'software' (MANDATORY) + - secondary_id: ID of the secondary entity (MANDATORY) + - secondary_type: Type of the secondary entity: 'asset', 'requester', 'agent', 'department', 'software' (MANDATORY) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships/bulk-create" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, headers=headers, json={"relationships": relationships}) + response.raise_for_status() + result = response.json() + return { + "success": True, + "message": "Bulk relationship creation job submitted. Use get_job_status() with the job_id to track progress.", + "job_id": result.get("job_id"), + "href": result.get("href"), + "data": result + } + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to create relationships: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to create relationships: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#DELETE RELATIONSHIPS IN BULK +@mcp.tool() +async def delete_asset_relationships( + relationship_ids: List[int] +) -> str: + """Delete asset relationships in bulk in Freshservice. + + Args: + relationship_ids: List of relationship IDs to delete + """ + ids_param = ",".join(str(rid) for rid in relationship_ids) + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationships?ids={ids_param}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.delete(url, headers=headers) + + if response.status_code == 204: + return "Relationships deleted successfully" + else: + try: + return f"Error: {response.json()}" + except Exception: + return f"Error: Unexpected response (status {response.status_code})" + except Exception as e: + return f"Error: An unexpected error occurred: {str(e)}" + + +#GET BACKGROUND JOB STATUS +@mcp.tool() +async def get_job_status(job_id: str) -> Dict[str, Any]: + """Get the status of a background job in Freshservice. + + Use this to track the progress of async operations like bulk relationship creation. + Possible status values: 'queued', 'in progress', 'partial', 'success', 'failed'. + - queued: Job is queued and ready to be executed + - in progress: Job execution started + - partial: Job completed with some successes and some failures + - success: All operations completed successfully + - failed: No operations succeeded + + Note: The job status URL is valid for one hour after creation. + + Args: + job_id: The job ID returned by a bulk operation (e.g., create_asset_relationships) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/jobs/{job_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch job status: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch job status: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET RELATIONSHIP TYPES +@mcp.tool() +async def get_relationship_types() -> Dict[str, Any]: + """List all relationship types available in Freshservice. + These define the kind of relationships between assets (e.g., 'Used By', 'Depends On', etc.). + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/relationship_types" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch relationship types: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch relationship types: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +# ============================================================================ +# ASSET TYPES +# ============================================================================ + +#GET ASSET TYPES +@mcp.tool() +async def get_asset_types(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: + """List all asset types in Freshservice (e.g., Hardware, Software, etc.). + + Args: + page: Page number (default: 1) + per_page: Number of items per page (1-100, default: 30) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/asset_types" + params = {"page": page, "per_page": per_page} + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + + link_header = response.headers.get('Link', '') + pagination_info = parse_link_header(link_header) + + return { + "asset_types": response.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page + } + } + except httpx.HTTPStatusError as e: + try: + return {"error": str(e), "details": e.response.json()} + except Exception: + return {"error": str(e), "raw_response": e.response.text} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +#GET ASSET TYPE BY ID +@mcp.tool() +async def get_asset_type_by_id(asset_type_id: int) -> Dict[str, Any]: + """Get a specific asset type by ID in Freshservice. + + Args: + asset_type_id: The ID of the asset type + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/asset_types/{asset_type_id}" + headers = get_auth_headers() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + try: + return {"error": f"Failed to fetch asset type: {str(e)}", "details": e.response.json()} + except Exception: + return {"error": f"Failed to fetch asset type: {str(e)}"} + except Exception as e: + return {"error": f"An unexpected error occurred: {str(e)}"} + + +# GET AUTH HEADERS +def get_auth_headers(): + return { + "Authorization": f"Basic {base64.b64encode(f'{FRESHSERVICE_APIKEY}:X'.encode()).decode()}", + "Content-Type": "application/json" + } + +def main(): + logging.info("Starting Freshservice MCP server") + mcp.run(transport='stdio') + +if __name__ == "__main__": + main() diff --git a/src/freshservice_mcp/tools/__init__.py b/src/freshservice_mcp/tools/__init__.py new file mode 100644 index 0000000..337aa95 --- /dev/null +++ b/src/freshservice_mcp/tools/__init__.py @@ -0,0 +1,38 @@ +"""Freshservice MCP — tools package. + +Each sub-module exposes a ``register_*_tools(mcp)`` function. +""" + +from .agents import register_agents_tools +from .assets import register_assets_tools +from .changes import register_changes_tools +from .misc import register_misc_tools +from .products import register_products_tools +from .requesters import register_requesters_tools +from .solutions import register_solutions_tools +from .tickets import register_tickets_tools + +# Mapping from scope name → registration function. +# Used by server.py to selectively load tool modules. +SCOPE_REGISTRY: dict[str, callable] = { + "tickets": register_tickets_tools, + "changes": register_changes_tools, + "assets": register_assets_tools, + "agents": register_agents_tools, + "requesters": register_requesters_tools, + "solutions": register_solutions_tools, + "products": register_products_tools, + "misc": register_misc_tools, +} + +__all__ = [ + "SCOPE_REGISTRY", + "register_agents_tools", + "register_assets_tools", + "register_changes_tools", + "register_misc_tools", + "register_products_tools", + "register_requesters_tools", + "register_solutions_tools", + "register_tickets_tools", +] diff --git a/src/freshservice_mcp/tools/agents.py b/src/freshservice_mcp/tools/agents.py new file mode 100644 index 0000000..e9a3704 --- /dev/null +++ b/src/freshservice_mcp/tools/agents.py @@ -0,0 +1,257 @@ +"""Freshservice MCP — Agents & Groups tools (consolidated). + +Exposes 2 tools instead of the original 10: + • manage_agent — CRUD + list + filter + get_fields + • manage_agent_group — CRUD + list + get +""" +from typing import Any, Dict, List, Optional + +from ..http_client import api_get, api_post, api_put, handle_error, parse_link_header + + +def register_agents_tools(mcp) -> None: + """Register agent-related tools on *mcp*.""" + + # ------------------------------------------------------------------ # + # manage_agent # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_agent( + action: str, + agent_id: Optional[int] = None, + # create / update + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email: Optional[str] = None, + occasional: Optional[bool] = None, + job_title: Optional[str] = None, + work_phone_number: Optional[int] = None, + mobile_phone_number: Optional[int] = None, + department_ids: Optional[List[int]] = None, + can_see_all_tickets_from_associated_departments: Optional[bool] = None, + reporting_manager_id: Optional[int] = None, + address: Optional[str] = None, + time_zone: Optional[str] = None, + time_format: Optional[str] = None, + language: Optional[str] = None, + location_id: Optional[int] = None, + background_information: Optional[str] = None, + scoreboard_level_id: Optional[int] = None, + # filter / list + query: Optional[str] = None, + page: int = 1, + per_page: int = 30, + ) -> Dict[str, Any]: + """Unified agent operations. + + Args: + action: 'create', 'update', 'get', 'list', 'filter', 'get_fields' + agent_id: Required for get, update + first_name: MANDATORY for create + email: Agent email (create) + query: Filter query string (filter) + page/per_page: Pagination (list) + """ + action = action.lower().strip() + + if action == "get_fields": + try: + resp = await api_get("agent_fields") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get agent fields") + + if action == "list": + try: + resp = await api_get("agents", params={"page": page, "per_page": per_page}) + resp.raise_for_status() + pagination_info = parse_link_header(resp.headers.get("Link", "")) + return { + "agents": resp.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + }, + } + except Exception as e: + return handle_error(e, "list agents") + + if action == "get": + if not agent_id: + return {"error": "agent_id required for get"} + try: + resp = await api_get(f"agents/{agent_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get agent") + + if action == "filter": + if not query: + return {"error": "query required for filter"} + import urllib.parse + encoded = urllib.parse.quote(f'"{query}"') + all_agents: List[Any] = [] + current_page = 1 + while True: + try: + resp = await api_get(f"agents?query={encoded}", params={"page": current_page}) + resp.raise_for_status() + data = resp.json() + agents = data.get("agents", []) + if not agents: + break + all_agents.extend(agents) + link = resp.headers.get("Link", "") + if 'rel="next"' not in link: + break + current_page += 1 + except Exception as e: + return handle_error(e, "filter agents") + return {"agents": all_agents, "total": len(all_agents)} + + if action == "create": + if not first_name: + return {"error": "first_name required for create"} + data: Dict[str, Any] = {"first_name": first_name} + for k, v in [("last_name", last_name), ("email", email), + ("occasional", occasional), ("job_title", job_title), + ("work_phone_number", work_phone_number), + ("mobile_phone_number", mobile_phone_number), + ("department_ids", department_ids), + ("reporting_manager_id", reporting_manager_id), + ("address", address), ("time_zone", time_zone), + ("time_format", time_format), ("language", language), + ("location_id", location_id), + ("background_information", background_information), + ("scoreboard_level_id", scoreboard_level_id), + ("can_see_all_tickets_from_associated_departments", + can_see_all_tickets_from_associated_departments)]: + if v is not None: + data[k] = v + try: + resp = await api_post("agents", json=data) + resp.raise_for_status() + return {"success": True, "agent": resp.json()} + except Exception as e: + return handle_error(e, "create agent") + + if action == "update": + if not agent_id: + return {"error": "agent_id required for update"} + data: Dict[str, Any] = {} + for k, v in [("first_name", first_name), ("last_name", last_name), + ("email", email), ("occasional", occasional), + ("job_title", job_title), + ("work_phone_number", work_phone_number), + ("mobile_phone_number", mobile_phone_number), + ("department_ids", department_ids), + ("reporting_manager_id", reporting_manager_id), + ("address", address), ("time_zone", time_zone), + ("time_format", time_format), ("language", language), + ("location_id", location_id), + ("background_information", background_information), + ("scoreboard_level_id", scoreboard_level_id), + ("can_see_all_tickets_from_associated_departments", + can_see_all_tickets_from_associated_departments)]: + if v is not None: + data[k] = v + if not data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"agents/{agent_id}", json=data) + resp.raise_for_status() + return {"success": True, "agent": resp.json()} + except Exception as e: + return handle_error(e, "update agent") + + return {"error": f"Unknown action '{action}'. Valid: create, update, get, list, filter, get_fields"} + + # ------------------------------------------------------------------ # + # manage_agent_group # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_agent_group( + action: str, + group_id: Optional[int] = None, + name: Optional[str] = None, + description: Optional[str] = None, + agent_ids: Optional[List[int]] = None, + auto_ticket_assign: Optional[bool] = None, + escalate_to: Optional[int] = None, + unassigned_for: Optional[str] = None, + group_fields: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Manage agent groups. + + Args: + action: 'create', 'update', 'get', 'list' + group_id: Required for get, update + name: Group name (create — MANDATORY) + description: Group description + agent_ids: List of agent IDs in the group + auto_ticket_assign: Auto-assign tickets + escalate_to: Agent ID for escalation + unassigned_for: Duration before escalation (e.g. '30m', '1h') + group_fields: Generic fields dict (update — alternative to explicit params) + """ + action = action.lower().strip() + + if action == "list": + try: + resp = await api_get("groups") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list agent groups") + + if action == "get": + if not group_id: + return {"error": "group_id required for get"} + try: + resp = await api_get(f"groups/{group_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get agent group") + + if action == "create": + if not name: + return {"error": "name required for create"} + data: Dict[str, Any] = {"name": name} + for k, v in [("description", description), ("agent_ids", agent_ids), + ("auto_ticket_assign", auto_ticket_assign), + ("escalate_to", escalate_to), + ("unassigned_for", unassigned_for)]: + if v is not None: + data[k] = v + try: + resp = await api_post("groups", json=data) + resp.raise_for_status() + return {"success": True, "group": resp.json()} + except Exception as e: + return handle_error(e, "create agent group") + + if action == "update": + if not group_id: + return {"error": "group_id required for update"} + data = group_fields or {} + for k, v in [("name", name), ("description", description), + ("agent_ids", agent_ids), + ("auto_ticket_assign", auto_ticket_assign), + ("escalate_to", escalate_to), + ("unassigned_for", unassigned_for)]: + if v is not None: + data[k] = v + if not data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"groups/{group_id}", json=data) + resp.raise_for_status() + return {"success": True, "group": resp.json()} + except Exception as e: + return handle_error(e, "update agent group") + + return {"error": f"Unknown action '{action}'. Valid: create, update, get, list"} diff --git a/src/freshservice_mcp/tools/assets.py b/src/freshservice_mcp/tools/assets.py new file mode 100644 index 0000000..f30babd --- /dev/null +++ b/src/freshservice_mcp/tools/assets.py @@ -0,0 +1,439 @@ +"""Freshservice MCP — Assets tools (consolidated). + +Exposes 3 tools instead of the original 22: + • manage_asset — CRUD + list + search + filter + delete + restore + move + get_types + • manage_asset_details — components, assignment history, requests, contracts + • manage_asset_relationship — CRUD + list + types + job status +""" +import urllib.parse +from typing import Any, Dict, List, Optional + +from ..http_client import ( + api_delete, + api_get, + api_post, + api_put, + handle_error, + parse_link_header, +) + + +def register_assets_tools(mcp) -> None: + """Register asset-related tools on *mcp*.""" + + # ------------------------------------------------------------------ # + # manage_asset # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_asset( + action: str, + # identifiers + display_id: Optional[int] = None, + asset_type_id: Optional[int] = None, + # create / update fields + name: Optional[str] = None, + asset_tag: Optional[str] = None, + impact: Optional[str] = None, + usage_type: Optional[str] = None, + description: Optional[str] = None, + user_id: Optional[int] = None, + location_id: Optional[int] = None, + department_id: Optional[int] = None, + agent_id: Optional[int] = None, + group_id: Optional[int] = None, + assigned_on: Optional[str] = None, + workspace_id: Optional[int] = None, + type_fields: Optional[Dict[str, Any]] = None, + asset_fields: Optional[Dict[str, Any]] = None, + # search / filter / list + search_query: Optional[str] = None, + filter_query: Optional[str] = None, + include: Optional[str] = None, + order_by: Optional[str] = None, + order_type: Optional[str] = None, + trashed: bool = False, + page: int = 1, + per_page: int = 30, + ) -> Dict[str, Any]: + """Unified asset operations. + + Args: + action: One of 'create', 'update', 'delete', 'delete_permanently', + 'restore', 'get', 'list', 'search', 'filter', 'move', + 'get_types', 'get_type' + display_id: Asset display ID (get, update, delete, restore, move, details) + asset_type_id: Asset type ID (create — MANDATORY, get_type) + name: Asset name (create — MANDATORY) + asset_tag: Asset tag (e.g. 'ASSET-9') + impact: 'low', 'medium', or 'high' (default: 'low') + usage_type: 'permanent' or 'loaner' (default: 'permanent') + description: Asset description + user_id: User ID (Used By) + location_id: Location ID + department_id: Department ID + agent_id: Agent ID (Managed By) + group_id: Group ID (Managed By Group) + assigned_on: ISO date when assigned + workspace_id: Workspace ID (create, list, move) + type_fields: Asset-type-specific fields dict + asset_fields: Generic update fields dict (update — alternative to explicit params) + search_query: Search by name/tag/serial (search) + filter_query: Filter expression (filter) + include: Include extra data, e.g. 'type_fields' (list, get) + order_by: Sort field (list) + order_type: 'asc' or 'desc' (list) + trashed: Include trashed assets (list, search) + page: Page number + per_page: Items per page + """ + action = action.lower().strip() + + # ---------- list ---------- + if action == "list": + params: Dict[str, Any] = {"page": page, "per_page": per_page} + if include: + params["include"] = include + if order_by: + params["order_by"] = order_by + if order_type: + params["order_type"] = order_type + if trashed: + params["trashed"] = "true" + if workspace_id is not None: + params["workspace_id"] = workspace_id + try: + resp = await api_get("assets", params=params) + resp.raise_for_status() + pagination_info = parse_link_header(resp.headers.get("Link", "")) + return { + "assets": resp.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page, + }, + } + except Exception as e: + return handle_error(e, "list assets") + + # ---------- get ---------- + if action == "get": + if not display_id: + return {"error": "display_id required for get"} + params = {} + if include: + params["include"] = include + try: + resp = await api_get(f"assets/{display_id}", params=params or None) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get asset") + + # ---------- search ---------- + if action == "search": + if not search_query: + return {"error": "search_query required for search"} + encoded = urllib.parse.quote(f'"{search_query}"') + params = {"page": page} + if trashed: + params["trashed"] = "true" + try: + resp = await api_get(f"assets?search={encoded}", params=params) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "search assets") + + # ---------- filter ---------- + if action == "filter": + if not filter_query: + return {"error": "filter_query required for filter"} + encoded = urllib.parse.quote(f'"{filter_query}"') + params: Dict[str, Any] = {"page": page} + if include: + params["include"] = include + try: + resp = await api_get(f"assets?filter={encoded}", params=params) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "filter assets") + + # ---------- create ---------- + if action == "create": + if not name or not asset_type_id: + return {"error": "name and asset_type_id are required for create"} + # Validate enums + valid_impacts = {"low", "medium", "high"} + valid_usage = {"permanent", "loaner"} + imp = (impact or "low").lower() + usg = (usage_type or "permanent").lower() + if imp not in valid_impacts: + return {"error": f"impact must be one of {valid_impacts}"} + if usg not in valid_usage: + return {"error": f"usage_type must be one of {valid_usage}"} + + data: Dict[str, Any] = { + "name": name, + "asset_type_id": asset_type_id, + "impact": imp, + "usage_type": usg, + } + for k, v in [("asset_tag", asset_tag), ("description", description), + ("user_id", user_id), ("location_id", location_id), + ("department_id", department_id), ("agent_id", agent_id), + ("group_id", group_id), ("assigned_on", assigned_on), + ("workspace_id", workspace_id)]: + if v is not None: + data[k] = v + if type_fields: + data["type_fields"] = type_fields + try: + resp = await api_post("assets", json=data) + resp.raise_for_status() + return {"success": True, "asset": resp.json()} + except Exception as e: + return handle_error(e, "create asset") + + # ---------- update ---------- + if action == "update": + if not display_id: + return {"error": "display_id required for update"} + fields = asset_fields or {} + # Allow explicit params to override + for k, v in [("name", name), ("asset_tag", asset_tag), + ("impact", impact), ("usage_type", usage_type), + ("description", description), ("user_id", user_id), + ("location_id", location_id), ("department_id", department_id), + ("agent_id", agent_id), ("group_id", group_id), + ("assigned_on", assigned_on)]: + if v is not None: + fields[k] = v + if type_fields: + fields["type_fields"] = type_fields + if not fields: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"assets/{display_id}", json=fields) + resp.raise_for_status() + return {"success": True, "asset": resp.json()} + except Exception as e: + return handle_error(e, "update asset") + + # ---------- delete ---------- + if action == "delete": + if not display_id: + return {"error": "display_id required for delete"} + try: + resp = await api_delete(f"assets/{display_id}") + if resp.status_code == 204: + return {"success": True, "message": "Asset moved to trash"} + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "delete asset") + + # ---------- delete_permanently ---------- + if action == "delete_permanently": + if not display_id: + return {"error": "display_id required for delete_permanently"} + try: + resp = await api_put(f"assets/{display_id}/delete_forever") + if resp.status_code == 204: + return {"success": True, "message": "Asset permanently deleted"} + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "permanently delete asset") + + # ---------- restore ---------- + if action == "restore": + if not display_id: + return {"error": "display_id required for restore"} + try: + resp = await api_put(f"assets/{display_id}/restore") + resp.raise_for_status() + return {"success": True, "message": "Asset restored"} + except Exception as e: + return handle_error(e, "restore asset") + + # ---------- move ---------- + if action == "move": + if not display_id or workspace_id is None: + return {"error": "display_id and workspace_id required for move"} + data = {"workspace_id": workspace_id} + if agent_id is not None: + data["agent_id"] = agent_id + if group_id is not None: + data["group_id"] = group_id + try: + resp = await api_put(f"assets/{display_id}/move_workspace", json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "move asset") + + # ---------- get_types ---------- + if action == "get_types": + params = {"page": page, "per_page": per_page} + try: + resp = await api_get("asset_types", params=params) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list asset types") + + # ---------- get_type ---------- + if action == "get_type": + if not asset_type_id: + return {"error": "asset_type_id required for get_type"} + try: + resp = await api_get(f"asset_types/{asset_type_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get asset type") + + return {"error": f"Unknown action '{action}'. Valid: create, update, delete, delete_permanently, restore, get, list, search, filter, move, get_types, get_type"} + + # ------------------------------------------------------------------ # + # manage_asset_details # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_asset_details( + action: str, + display_id: int, + ) -> Dict[str, Any]: + """Retrieve asset sub-resources. + + Args: + action: 'components', 'assignment_history', 'requests', 'contracts' + display_id: The asset display ID + """ + action = action.lower().strip() + endpoints = { + "components": "components", + "assignment_history": "assignment-history", + "requests": "requests", + "contracts": "contracts", + } + if action not in endpoints: + return {"error": f"Unknown action '{action}'. Valid: {', '.join(endpoints)}"} + try: + resp = await api_get(f"assets/{display_id}/{endpoints[action]}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, f"get asset {action}") + + # ------------------------------------------------------------------ # + # manage_asset_relationship # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_asset_relationship( + action: str, + display_id: Optional[int] = None, + relationship_id: Optional[int] = None, + relationship_ids: Optional[List[int]] = None, + relationships: Optional[List[Dict[str, Any]]] = None, + job_id: Optional[str] = None, + page: int = 1, + per_page: int = 30, + ) -> Dict[str, Any]: + """Manage asset relationships. + + Args: + action: 'list_for_asset', 'list_all', 'get', 'create', 'delete', + 'get_types', 'job_status' + display_id: Asset display ID (list_for_asset) + relationship_id: Relationship ID (get) + relationship_ids: List of rel IDs to delete (delete) + relationships: List of relationship dicts for bulk create (create). + Each dict: {relationship_type_id, primary_id, primary_type, + secondary_id, secondary_type} + job_id: Job ID returned by async operations (job_status) + page: Page number (list_all) + per_page: Items per page (list_all) + """ + action = action.lower().strip() + + if action == "list_for_asset": + if not display_id: + return {"error": "display_id required for list_for_asset"} + try: + resp = await api_get(f"assets/{display_id}/relationships") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list asset relationships") + + if action == "list_all": + try: + resp = await api_get("relationships", params={"page": page, "per_page": per_page}) + resp.raise_for_status() + pagination_info = parse_link_header(resp.headers.get("Link", "")) + return { + "relationships": resp.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + }, + } + except Exception as e: + return handle_error(e, "list all relationships") + + if action == "get": + if not relationship_id: + return {"error": "relationship_id required for get"} + try: + resp = await api_get(f"relationships/{relationship_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get relationship") + + if action == "create": + if not relationships: + return {"error": "relationships list required for create"} + try: + resp = await api_post("relationships/bulk-create", json=relationships) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "create relationships") + + if action == "delete": + if not relationship_ids: + return {"error": "relationship_ids list required for delete"} + ids_str = ",".join(str(i) for i in relationship_ids) + try: + resp = await api_delete(f"relationships?ids={ids_str}") + if resp.status_code == 204: + return {"success": True, "message": "Relationships deleted"} + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "delete relationships") + + if action == "get_types": + try: + resp = await api_get("relationship_types") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get relationship types") + + if action == "job_status": + if not job_id: + return {"error": "job_id required for job_status"} + try: + resp = await api_get(f"jobs/{job_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get job status") + + return {"error": f"Unknown action '{action}'. Valid: list_for_asset, list_all, get, create, delete, get_types, job_status"} diff --git a/src/freshservice_mcp/tools/changes.py b/src/freshservice_mcp/tools/changes.py new file mode 100644 index 0000000..eea8d2c --- /dev/null +++ b/src/freshservice_mcp/tools/changes.py @@ -0,0 +1,697 @@ +"""Freshservice MCP — Changes tools (consolidated). + +Exposes 5 tools instead of the original 33: + • manage_change — CRUD + list + filter + close + move + get_fields + • manage_change_note — create, view, list, update, delete + • manage_change_task — create, view, list, update, delete + • manage_change_time_entry — create, view, list, update, delete + • manage_change_approval — groups + approvals CRUD, chain rule, reminders +""" +import urllib.parse +from typing import Any, Dict, List, Optional, Union + +from ..config import ( + ChangeImpact, + ChangePriority, + ChangeRisk, + ChangeStatus, + ChangeType, +) +from ..http_client import ( + api_delete, + api_get, + api_post, + api_put, + handle_error, + parse_link_header, +) + + +# ── registration ─────────────────────────────────────────────────────────── +def register_changes_tools(mcp) -> None: # noqa: C901 – large by nature + """Register change-related tools on *mcp*.""" + + # ------------------------------------------------------------------ # + # manage_change # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_change( + action: str, + change_id: Optional[int] = None, + # create / update fields + requester_id: Optional[int] = None, + subject: Optional[str] = None, + description: Optional[str] = None, + priority: Optional[Union[int, str]] = None, + impact: Optional[Union[int, str]] = None, + status: Optional[Union[int, str]] = None, + risk: Optional[Union[int, str]] = None, + change_type: Optional[Union[int, str]] = None, + group_id: Optional[int] = None, + agent_id: Optional[int] = None, + department_id: Optional[int] = None, + category: Optional[str] = None, + sub_category: Optional[str] = None, + item_category: Optional[str] = None, + planned_start_date: Optional[str] = None, + planned_end_date: Optional[str] = None, + reason_for_change: Optional[str] = None, + change_impact: Optional[str] = None, + rollout_plan: Optional[str] = None, + backout_plan: Optional[str] = None, + custom_fields: Optional[Dict[str, Any]] = None, + assets: Optional[List[Dict[str, Any]]] = None, + # close + change_result_explanation: Optional[str] = None, + # move + workspace_id: Optional[int] = None, + # list / filter + query: Optional[str] = None, + view: Optional[str] = None, + sort: Optional[str] = None, + order_by: Optional[str] = None, + updated_since: Optional[str] = None, + page: int = 1, + per_page: int = 30, + ) -> Dict[str, Any]: + """Unified change operations. + + Args: + action: One of 'create', 'update', 'delete', 'get', 'list', 'filter', + 'close', 'move', 'get_fields' + change_id: Required for get, update, delete, close, move + requester_id: Initiator ID (create — MANDATORY) + subject: Change subject (create — MANDATORY) + description: HTML description (create — MANDATORY) + priority: 1=Low, 2=Medium, 3=High, 4=Urgent + impact: 1=Low, 2=Medium, 3=High + status: 1=Open, 2=Planning, 3=Awaiting Approval, 4=Pending Release, + 5=Pending Review, 6=Closed + risk: 1=Low, 2=Medium, 3=High, 4=Very High + change_type: 1=Minor, 2=Standard, 3=Major, 4=Emergency + group_id: Agent group ID + agent_id: Agent ID + department_id: Department ID + category: Category string + sub_category: Sub-category string + item_category: Item category string + planned_start_date: ISO datetime + planned_end_date: ISO datetime + reason_for_change: Planning field — reason (text/HTML) + change_impact: Planning field — impact analysis (text/HTML) + rollout_plan: Planning field — rollout plan (text/HTML) + backout_plan: Planning field — backout plan (text/HTML) + custom_fields: Custom fields dict + assets: Assets list, e.g. [{"display_id": 1}] + change_result_explanation: Result explanation (close) + workspace_id: Target workspace (move / list / filter) + query: Filter query string (list/filter) + view: View name or ID (list) + sort: Sort field (list) + order_by: 'asc' or 'desc' (list) + updated_since: ISO datetime (list) + page: Page number + per_page: Items per page 1-100 + """ + action = action.lower().strip() + + # ---------- get_fields ---------- + if action == "get_fields": + try: + resp = await api_get("change_form_fields") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "fetch change fields") + + # ---------- list / filter ---------- + if action in ("list", "filter"): + params: Dict[str, Any] = {"page": page, "per_page": per_page} + if query: + params["query"] = query + if view: + params["view"] = view + if sort: + params["sort"] = sort + if order_by: + params["order_by"] = order_by + if updated_since: + params["updated_since"] = updated_since + if workspace_id is not None: + params["workspace_id"] = workspace_id + try: + resp = await api_get("changes", params=params) + resp.raise_for_status() + pagination_info = parse_link_header(resp.headers.get("Link", "")) + return { + "changes": resp.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page, + }, + } + except Exception as e: + return handle_error(e, "list changes") + + # ---------- get ---------- + if action == "get": + if not change_id: + return {"error": "change_id required for get"} + try: + resp = await api_get(f"changes/{change_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get change") + + # ---------- create ---------- + if action == "create": + if not requester_id or not subject or not description: + return {"error": "requester_id, subject, and description are required for create"} + # Validate enums + try: + p = int(priority) if priority else ChangePriority.LOW.value + im = int(impact) if impact else ChangeImpact.LOW.value + st = int(status) if status else ChangeStatus.OPEN.value + ri = int(risk) if risk else ChangeRisk.LOW.value + ct = int(change_type) if change_type else ChangeType.STANDARD.value + except ValueError: + return {"error": "Invalid value for priority, impact, status, risk, or change_type"} + + data: Dict[str, Any] = { + "requester_id": requester_id, + "subject": subject, + "description": description, + "priority": p, + "impact": im, + "status": st, + "risk": ri, + "change_type": ct, + } + for k, v in [("group_id", group_id), ("agent_id", agent_id), + ("department_id", department_id), ("category", category), + ("sub_category", sub_category), ("item_category", item_category), + ("planned_start_date", planned_start_date), + ("planned_end_date", planned_end_date)]: + if v is not None: + data[k] = v + + # planning fields + planning = {} + for fname, fval in [("reason_for_change", reason_for_change), + ("change_impact", change_impact), + ("rollout_plan", rollout_plan), + ("backout_plan", backout_plan)]: + if fval is not None: + planning[fname] = {"description": fval} + if planning: + data["planning_fields"] = planning + if custom_fields: + data["custom_fields"] = custom_fields + if assets: + data["assets"] = assets + + try: + resp = await api_post("changes", json=data) + resp.raise_for_status() + return {"success": True, "change": resp.json()} + except Exception as e: + return handle_error(e, "create change") + + # ---------- update ---------- + if action == "update": + if not change_id: + return {"error": "change_id required for update"} + update_data: Dict[str, Any] = {} + for k, v in [("subject", subject), ("description", description), + ("group_id", group_id), ("agent_id", agent_id), + ("department_id", department_id), ("category", category), + ("sub_category", sub_category), ("item_category", item_category), + ("planned_start_date", planned_start_date), + ("planned_end_date", planned_end_date)]: + if v is not None: + update_data[k] = v + for k, v in [("priority", priority), ("impact", impact), + ("status", status), ("risk", risk), + ("change_type", change_type)]: + if v is not None: + try: + update_data[k] = int(v) + except ValueError: + return {"error": f"Invalid {k} value: {v}"} + if custom_fields: + update_data["custom_fields"] = custom_fields + if assets: + update_data["assets"] = assets + planning = {} + for fname, fval in [("reason_for_change", reason_for_change), + ("change_impact", change_impact), + ("rollout_plan", rollout_plan), + ("backout_plan", backout_plan)]: + if fval is not None: + planning[fname] = {"description": fval} + if planning: + update_data["planning_fields"] = planning + if not update_data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"changes/{change_id}", json=update_data) + resp.raise_for_status() + return {"success": True, "change": resp.json()} + except Exception as e: + return handle_error(e, "update change") + + # ---------- close ---------- + if action == "close": + if not change_id: + return {"error": "change_id required for close"} + close_data: Dict[str, Any] = {"status": ChangeStatus.CLOSED.value} + cf = dict(custom_fields or {}) + if change_result_explanation: + cf["change_result_explanation"] = change_result_explanation + if cf: + close_data["custom_fields"] = cf + try: + resp = await api_put(f"changes/{change_id}", json=close_data) + resp.raise_for_status() + return {"success": True, "change": resp.json()} + except Exception as e: + return handle_error(e, "close change") + + # ---------- delete ---------- + if action == "delete": + if not change_id: + return {"error": "change_id required for delete"} + try: + resp = await api_delete(f"changes/{change_id}") + if resp.status_code == 204: + return {"success": True, "message": "Change deleted"} + return {"error": f"Unexpected status {resp.status_code}"} + except Exception as e: + return handle_error(e, "delete change") + + # ---------- move ---------- + if action == "move": + if not change_id or workspace_id is None: + return {"error": "change_id and workspace_id required for move"} + try: + resp = await api_put(f"changes/{change_id}/move_workspace", json={"workspace_id": workspace_id}) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "move change") + + return {"error": f"Unknown action '{action}'. Valid: create, update, delete, get, list, filter, close, move, get_fields"} + + # ------------------------------------------------------------------ # + # manage_change_note # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_change_note( + action: str, + change_id: int, + note_id: Optional[int] = None, + body: Optional[str] = None, + ) -> Dict[str, Any]: + """Manage notes on a change. + + Args: + action: 'create', 'view', 'list', 'update', 'delete' + change_id: The change ID + note_id: Required for view, update, delete + body: Note body HTML (create, update) + """ + action = action.lower().strip() + base = f"changes/{change_id}/notes" + + if action == "list": + try: + resp = await api_get(base) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list change notes") + + if action == "create": + if not body: + return {"error": "body required for create"} + try: + resp = await api_post(base, json={"body": body}) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "create change note") + + if action == "view": + if not note_id: + return {"error": "note_id required for view"} + try: + resp = await api_get(f"{base}/{note_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "view change note") + + if action == "update": + if not note_id or not body: + return {"error": "note_id and body required for update"} + try: + resp = await api_put(f"{base}/{note_id}", json={"body": body}) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "update change note") + + if action == "delete": + if not note_id: + return {"error": "note_id required for delete"} + try: + resp = await api_delete(f"{base}/{note_id}") + if resp.status_code == 204: + return {"success": True, "message": "Note deleted"} + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "delete change note") + + return {"error": f"Unknown action '{action}'. Valid: create, view, list, update, delete"} + + # ------------------------------------------------------------------ # + # manage_change_task # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_change_task( + action: str, + change_id: int, + task_id: Optional[int] = None, + title: Optional[str] = None, + description: Optional[str] = None, + task_status: Optional[int] = None, + task_priority: Optional[int] = None, + assigned_to_id: Optional[int] = None, + task_group_id: Optional[int] = None, + due_date: Optional[str] = None, + task_fields: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Manage tasks on a change. + + Args: + action: 'create', 'view', 'list', 'update', 'delete' + change_id: The change ID + task_id: Required for view, update, delete + title: Task title (create) + description: Task description (create) + task_status: Task status int (create/update) + task_priority: Task priority int (create/update) + assigned_to_id: Agent ID to assign (create/update) + task_group_id: Group ID (create/update) + due_date: ISO date (create/update) + task_fields: Dict of fields (update — alternative to individual params) + """ + action = action.lower().strip() + base = f"changes/{change_id}/tasks" + + if action == "list": + try: + resp = await api_get(base) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list change tasks") + + if action == "create": + if not title or not description: + return {"error": "title and description required for create"} + data: Dict[str, Any] = {"title": title, "description": description} + if task_status is not None: + data["status"] = task_status + if task_priority is not None: + data["priority"] = task_priority + if assigned_to_id: + data["assigned_to_id"] = assigned_to_id + if task_group_id: + data["group_id"] = task_group_id + if due_date: + data["due_date"] = due_date + try: + resp = await api_post(base, json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "create change task") + + if action == "view": + if not task_id: + return {"error": "task_id required for view"} + try: + resp = await api_get(f"{base}/{task_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "view change task") + + if action == "update": + if not task_id: + return {"error": "task_id required for update"} + fields = task_fields or {} + try: + resp = await api_put(f"{base}/{task_id}", json=fields) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "update change task") + + if action == "delete": + if not task_id: + return {"error": "task_id required for delete"} + try: + resp = await api_delete(f"{base}/{task_id}") + if resp.status_code == 204: + return {"success": True, "message": "Task deleted"} + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "delete change task") + + return {"error": f"Unknown action '{action}'. Valid: create, view, list, update, delete"} + + # ------------------------------------------------------------------ # + # manage_change_time_entry # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_change_time_entry( + action: str, + change_id: int, + time_entry_id: Optional[int] = None, + time_spent: Optional[str] = None, + note: Optional[str] = None, + te_agent_id: Optional[int] = None, + executed_at: Optional[str] = None, + ) -> Dict[str, Any]: + """Manage time entries on a change. + + Args: + action: 'create', 'view', 'list', 'update', 'delete' + change_id: The change ID + time_entry_id: Required for view, update, delete + time_spent: Format "hh:mm" (create/update) + note: Work description (create/update) + te_agent_id: Agent ID who did the work (create) + executed_at: ISO datetime (create) + """ + action = action.lower().strip() + base = f"changes/{change_id}/time_entries" + + if action == "list": + try: + resp = await api_get(base) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list time entries") + + if action == "create": + if not time_spent or not note or not te_agent_id: + return {"error": "time_spent, note, and te_agent_id required for create"} + data: Dict[str, Any] = {"time_spent": time_spent, "note": note, "agent_id": te_agent_id} + if executed_at: + data["executed_at"] = executed_at + try: + resp = await api_post(base, json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "create time entry") + + if action == "view": + if not time_entry_id: + return {"error": "time_entry_id required for view"} + try: + resp = await api_get(f"{base}/{time_entry_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "view time entry") + + if action == "update": + if not time_entry_id: + return {"error": "time_entry_id required for update"} + data = {} + if time_spent is not None: + data["time_spent"] = time_spent + if note is not None: + data["note"] = note + try: + resp = await api_put(f"{base}/{time_entry_id}", json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "update time entry") + + if action == "delete": + if not time_entry_id: + return {"error": "time_entry_id required for delete"} + try: + resp = await api_delete(f"{base}/{time_entry_id}") + if resp.status_code == 204: + return {"success": True, "message": "Time entry deleted"} + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "delete time entry") + + return {"error": f"Unknown action '{action}'. Valid: create, view, list, update, delete"} + + # ------------------------------------------------------------------ # + # manage_change_approval # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_change_approval( + action: str, + change_id: int, + approval_id: Optional[int] = None, + approval_group_id: Optional[int] = None, + name: Optional[str] = None, + approver_ids: Optional[List[int]] = None, + approval_type: Optional[str] = None, + approval_chain_type: Optional[str] = None, + ) -> Dict[str, Any]: + """Manage approvals and approval groups for a change. + + Args: + action: 'list_groups', 'create_group', 'update_group', 'cancel_group', + 'list', 'view', 'remind', 'cancel', 'set_chain_rule' + change_id: The change ID + approval_id: Approval ID (view, remind, cancel) + approval_group_id: Approval group ID (update_group, cancel_group) + name: Group name (create_group, update_group) + approver_ids: List of agent IDs (create_group, update_group) + approval_type: 'everyone' or 'any' (create_group, update_group) + approval_chain_type: 'parallel' or 'sequential' (set_chain_rule) + """ + action = action.lower().strip() + + # -- approval groups -- + if action == "list_groups": + try: + resp = await api_get(f"changes/{change_id}/approval_groups") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list approval groups") + + if action == "create_group": + if not name or not approver_ids: + return {"error": "name and approver_ids required for create_group"} + data: Dict[str, Any] = { + "name": name, + "approver_ids": approver_ids, + "approval_type": approval_type or "everyone", + } + try: + resp = await api_post(f"changes/{change_id}/approval_groups", json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "create approval group") + + if action == "update_group": + if not approval_group_id: + return {"error": "approval_group_id required for update_group"} + data = {} + if name is not None: + data["name"] = name + if approver_ids is not None: + data["approver_ids"] = approver_ids + if approval_type is not None: + data["approval_type"] = approval_type + try: + resp = await api_put(f"changes/{change_id}/approval_groups/{approval_group_id}", json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "update approval group") + + if action == "cancel_group": + if not approval_group_id: + return {"error": "approval_group_id required for cancel_group"} + try: + resp = await api_put(f"changes/{change_id}/approval_groups/{approval_group_id}/cancel") + resp.raise_for_status() + return {"success": True, "message": "Approval group cancelled"} + except Exception as e: + return handle_error(e, "cancel approval group") + + # -- individual approvals -- + if action == "list": + try: + resp = await api_get(f"changes/{change_id}/approvals") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list approvals") + + if action == "view": + if not approval_id: + return {"error": "approval_id required for view"} + try: + resp = await api_get(f"changes/{change_id}/approvals/{approval_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "view approval") + + if action == "remind": + if not approval_id: + return {"error": "approval_id required for remind"} + try: + resp = await api_put(f"changes/{change_id}/approvals/{approval_id}/resend_approval") + resp.raise_for_status() + return {"success": True, "message": "Reminder sent"} + except Exception as e: + return handle_error(e, "send approval reminder") + + if action == "cancel": + if not approval_id: + return {"error": "approval_id required for cancel"} + try: + resp = await api_put(f"changes/{change_id}/approvals/{approval_id}/cancel") + resp.raise_for_status() + return {"success": True, "message": "Approval cancelled"} + except Exception as e: + return handle_error(e, "cancel approval") + + # -- chain rule -- + if action == "set_chain_rule": + if approval_chain_type not in ("parallel", "sequential"): + return {"error": "approval_chain_type must be 'parallel' or 'sequential'"} + try: + resp = await api_put(f"changes/{change_id}/approval_chain", json={"approval_chain_type": approval_chain_type}) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "set chain rule") + + return {"error": f"Unknown action '{action}'. Valid: create_group, update_group, cancel_group, list_groups, list, view, remind, cancel, set_chain_rule"} diff --git a/src/freshservice_mcp/tools/misc.py b/src/freshservice_mcp/tools/misc.py new file mode 100644 index 0000000..288c7cd --- /dev/null +++ b/src/freshservice_mcp/tools/misc.py @@ -0,0 +1,105 @@ +"""Freshservice MCP — Canned Responses & Workspaces tools (consolidated). + +Exposes 2 tools instead of the original 6: + • manage_canned_response — list + get (responses & folders) + • manage_workspace — list + get +""" +from typing import Any, Dict, Optional + +from ..http_client import api_get, handle_error + + +def register_misc_tools(mcp) -> None: + """Register canned response and workspace tools on *mcp*.""" + + # ------------------------------------------------------------------ # + # manage_canned_response # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_canned_response( + action: str, + response_id: Optional[int] = None, + folder_id: Optional[int] = None, + ) -> Dict[str, Any]: + """Manage canned responses. + + Args: + action: 'list', 'get', 'list_folders', 'get_folder' + response_id: Required for get + folder_id: Required for get_folder + """ + action = action.lower().strip() + + if action == "list": + try: + resp = await api_get("canned_responses") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list canned responses") + + if action == "get": + if not response_id: + return {"error": "response_id required for get"} + try: + resp = await api_get(f"canned_responses/{response_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get canned response") + + if action == "list_folders": + try: + resp = await api_get("canned_response_folders") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list canned response folders") + + if action == "get_folder": + if not folder_id: + return {"error": "folder_id required for get_folder"} + try: + resp = await api_get(f"canned_response_folders/{folder_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get canned response folder") + + return {"error": f"Unknown action '{action}'. Valid: list, get, list_folders, get_folder"} + + # ------------------------------------------------------------------ # + # manage_workspace # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_workspace( + action: str, + workspace_id: Optional[int] = None, + ) -> Dict[str, Any]: + """Manage workspaces. + + Args: + action: 'list', 'get' + workspace_id: Required for get + """ + action = action.lower().strip() + + if action == "list": + try: + resp = await api_get("workspaces") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list workspaces") + + if action == "get": + if not workspace_id: + return {"error": "workspace_id required for get"} + try: + resp = await api_get(f"workspaces/{workspace_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get workspace") + + return {"error": f"Unknown action '{action}'. Valid: list, get"} diff --git a/src/freshservice_mcp/tools/products.py b/src/freshservice_mcp/tools/products.py new file mode 100644 index 0000000..e2e89ef --- /dev/null +++ b/src/freshservice_mcp/tools/products.py @@ -0,0 +1,111 @@ +"""Freshservice MCP — Products tools (consolidated). + +Exposes 1 tool instead of the original 4: + • manage_product — CRUD + list +""" +from typing import Any, Dict, Optional, Union + +from ..http_client import api_get, api_post, api_put, handle_error, parse_link_header + + +def register_products_tools(mcp) -> None: + """Register product-related tools on *mcp*.""" + + @mcp.tool() + async def manage_product( + action: str, + product_id: Optional[int] = None, + name: Optional[str] = None, + asset_type_id: Optional[int] = None, + manufacturer: Optional[str] = None, + status: Optional[Union[str, int]] = None, + mode_of_procurement: Optional[str] = None, + depreciation_type_id: Optional[int] = None, + description: Optional[str] = None, + description_text: Optional[str] = None, + page: int = 1, + per_page: int = 30, + ) -> Dict[str, Any]: + """Unified product operations. + + Args: + action: 'create', 'update', 'get', 'list' + product_id: Required for get, update + name: Product name (create — MANDATORY) + asset_type_id: Asset type ID (create — MANDATORY) + manufacturer: Manufacturer name + status: Product status (str or int) + mode_of_procurement: e.g. 'buy', 'lease' + depreciation_type_id: Depreciation type ID + description: HTML description + description_text: Plain text description + page/per_page: Pagination (list) + """ + action = action.lower().strip() + + if action == "list": + try: + resp = await api_get("products", params={"page": page, "per_page": per_page}) + resp.raise_for_status() + pagination_info = parse_link_header(resp.headers.get("Link", "")) + return { + "products": resp.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + }, + } + except Exception as e: + return handle_error(e, "list products") + + if action == "get": + if not product_id: + return {"error": "product_id required for get"} + try: + resp = await api_get(f"products/{product_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get product") + + if action == "create": + if not name or not asset_type_id: + return {"error": "name and asset_type_id required for create"} + data: Dict[str, Any] = {"name": name, "asset_type_id": asset_type_id} + for k, v in [("manufacturer", manufacturer), ("status", status), + ("mode_of_procurement", mode_of_procurement), + ("depreciation_type_id", depreciation_type_id), + ("description", description), + ("description_text", description_text)]: + if v is not None: + data[k] = v + try: + resp = await api_post("products", json=data) + resp.raise_for_status() + return {"success": True, "product": resp.json()} + except Exception as e: + return handle_error(e, "create product") + + if action == "update": + if not product_id: + return {"error": "product_id required for update"} + data: Dict[str, Any] = {} + for k, v in [("name", name), ("asset_type_id", asset_type_id), + ("manufacturer", manufacturer), ("status", status), + ("mode_of_procurement", mode_of_procurement), + ("depreciation_type_id", depreciation_type_id), + ("description", description), + ("description_text", description_text)]: + if v is not None: + data[k] = v + if not data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"products/{product_id}", json=data) + resp.raise_for_status() + return {"success": True, "product": resp.json()} + except Exception as e: + return handle_error(e, "update product") + + return {"error": f"Unknown action '{action}'. Valid: create, update, get, list"} diff --git a/src/freshservice_mcp/tools/requesters.py b/src/freshservice_mcp/tools/requesters.py new file mode 100644 index 0000000..49b0057 --- /dev/null +++ b/src/freshservice_mcp/tools/requesters.py @@ -0,0 +1,268 @@ +"""Freshservice MCP — Requesters & Requester Groups tools (consolidated). + +Exposes 2 tools instead of the original 12: + • manage_requester — CRUD + list + filter + get_fields + add_to_group + • manage_requester_group — CRUD + list + get + list_members +""" +from typing import Any, Dict, List, Optional + +from ..http_client import api_get, api_post, api_put, handle_error, parse_link_header + + +def register_requesters_tools(mcp) -> None: + """Register requester-related tools on *mcp*.""" + + # ------------------------------------------------------------------ # + # manage_requester # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_requester( + action: str, + requester_id: Optional[int] = None, + # create / update + first_name: Optional[str] = None, + last_name: Optional[str] = None, + job_title: Optional[str] = None, + primary_email: Optional[str] = None, + secondary_emails: Optional[List[str]] = None, + work_phone_number: Optional[str] = None, + mobile_phone_number: Optional[str] = None, + department_ids: Optional[List[int]] = None, + can_see_all_tickets_from_associated_departments: Optional[bool] = None, + reporting_manager_id: Optional[int] = None, + address: Optional[str] = None, + time_zone: Optional[str] = None, + time_format: Optional[str] = None, + language: Optional[str] = None, + location_id: Optional[int] = None, + background_information: Optional[str] = None, + custom_fields: Optional[Dict[str, Any]] = None, + # filter + query: Optional[str] = None, + include_agents: bool = False, + # add_to_group + group_id: Optional[int] = None, + # list + page: int = 1, + per_page: int = 30, + ) -> Dict[str, Any]: + """Unified requester operations. + + Args: + action: 'create', 'update', 'get', 'list', 'filter', 'get_fields', 'add_to_group' + requester_id: Required for get, update, add_to_group + first_name: MANDATORY for create + query: Filter query string (filter) + include_agents: Include agents in filter results (filter) + group_id: Group ID (add_to_group) + page/per_page: Pagination (list) + """ + action = action.lower().strip() + + if action == "get_fields": + try: + resp = await api_get("requester_fields") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get requester fields") + + if action == "list": + try: + resp = await api_get("requesters", params={"page": page, "per_page": per_page}) + resp.raise_for_status() + pagination_info = parse_link_header(resp.headers.get("Link", "")) + return { + "requesters": resp.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + }, + } + except Exception as e: + return handle_error(e, "list requesters") + + if action == "get": + if not requester_id: + return {"error": "requester_id required for get"} + try: + resp = await api_get(f"requesters/{requester_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get requester") + + if action == "filter": + if not query: + return {"error": "query required for filter"} + import urllib.parse + encoded = urllib.parse.quote(query) + params: Dict[str, Any] = {} + if include_agents: + params["include_agents"] = "true" + try: + resp = await api_get(f"requesters?query={encoded}", params=params) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "filter requesters") + + if action == "create": + if not first_name: + return {"error": "first_name required for create"} + data: Dict[str, Any] = {"first_name": first_name} + for k, v in [("last_name", last_name), ("job_title", job_title), + ("primary_email", primary_email), + ("secondary_emails", secondary_emails), + ("work_phone_number", work_phone_number), + ("mobile_phone_number", mobile_phone_number), + ("department_ids", department_ids), + ("can_see_all_tickets_from_associated_departments", + can_see_all_tickets_from_associated_departments), + ("reporting_manager_id", reporting_manager_id), + ("address", address), ("time_zone", time_zone), + ("time_format", time_format), ("language", language), + ("location_id", location_id), + ("background_information", background_information), + ("custom_fields", custom_fields)]: + if v is not None: + data[k] = v + try: + resp = await api_post("requesters", json=data) + resp.raise_for_status() + return {"success": True, "requester": resp.json()} + except Exception as e: + return handle_error(e, "create requester") + + if action == "update": + if not requester_id: + return {"error": "requester_id required for update"} + data: Dict[str, Any] = {} + for k, v in [("first_name", first_name), ("last_name", last_name), + ("job_title", job_title), ("primary_email", primary_email), + ("secondary_emails", secondary_emails), + ("work_phone_number", work_phone_number), + ("mobile_phone_number", mobile_phone_number), + ("department_ids", department_ids), + ("can_see_all_tickets_from_associated_departments", + can_see_all_tickets_from_associated_departments), + ("reporting_manager_id", reporting_manager_id), + ("address", address), ("time_zone", time_zone), + ("time_format", time_format), ("language", language), + ("location_id", location_id), + ("background_information", background_information), + ("custom_fields", custom_fields)]: + if v is not None: + data[k] = v + if not data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"requesters/{requester_id}", json=data) + resp.raise_for_status() + return {"success": True, "requester": resp.json()} + except Exception as e: + return handle_error(e, "update requester") + + if action == "add_to_group": + if not requester_id or not group_id: + return {"error": "requester_id and group_id required for add_to_group"} + try: + resp = await api_post(f"requester_groups/{group_id}/members/{requester_id}") + resp.raise_for_status() + return {"success": True, "message": "Requester added to group"} + except Exception as e: + return handle_error(e, "add requester to group") + + return {"error": f"Unknown action '{action}'. Valid: create, update, get, list, filter, get_fields, add_to_group"} + + # ------------------------------------------------------------------ # + # manage_requester_group # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_requester_group( + action: str, + group_id: Optional[int] = None, + name: Optional[str] = None, + description: Optional[str] = None, + page: int = 1, + per_page: int = 30, + ) -> Dict[str, Any]: + """Manage requester groups. + + Args: + action: 'create', 'update', 'get', 'list', 'list_members' + group_id: Required for get, update, list_members + name: Group name (create — MANDATORY) + description: Group description + page/per_page: Pagination (list) + """ + action = action.lower().strip() + + if action == "list": + try: + resp = await api_get("requester_groups", params={"page": page, "per_page": per_page}) + resp.raise_for_status() + pagination_info = parse_link_header(resp.headers.get("Link", "")) + return { + "requester_groups": resp.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + }, + } + except Exception as e: + return handle_error(e, "list requester groups") + + if action == "get": + if not group_id: + return {"error": "group_id required for get"} + try: + resp = await api_get(f"requester_groups/{group_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get requester group") + + if action == "create": + if not name: + return {"error": "name required for create"} + data: Dict[str, Any] = {"name": name} + if description is not None: + data["description"] = description + try: + resp = await api_post("requester_groups", json=data) + resp.raise_for_status() + return {"success": True, "requester_group": resp.json()} + except Exception as e: + return handle_error(e, "create requester group") + + if action == "update": + if not group_id: + return {"error": "group_id required for update"} + data: Dict[str, Any] = {} + if name is not None: + data["name"] = name + if description is not None: + data["description"] = description + if not data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"requester_groups/{group_id}", json=data) + resp.raise_for_status() + return {"success": True, "requester_group": resp.json()} + except Exception as e: + return handle_error(e, "update requester group") + + if action == "list_members": + if not group_id: + return {"error": "group_id required for list_members"} + try: + resp = await api_get(f"requester_groups/{group_id}/members") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list requester group members") + + return {"error": f"Unknown action '{action}'. Valid: create, update, get, list, list_members"} diff --git a/src/freshservice_mcp/tools/solutions.py b/src/freshservice_mcp/tools/solutions.py new file mode 100644 index 0000000..11f8a51 --- /dev/null +++ b/src/freshservice_mcp/tools/solutions.py @@ -0,0 +1,240 @@ +"""Freshservice MCP — Solutions tools (consolidated). + +Exposes 1 tool instead of the original 13: + • manage_solution — categories, folders, articles CRUD +""" +from typing import Any, Dict, List, Optional + +from ..http_client import api_get, api_post, api_put, handle_error + + +def register_solutions_tools(mcp) -> None: + """Register solution-related tools on *mcp*.""" + + @mcp.tool() + async def manage_solution( + action: str, + # identifiers + category_id: Optional[int] = None, + folder_id: Optional[int] = None, + article_id: Optional[int] = None, + # create / update + name: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + visibility: Optional[int] = None, + default_category: Optional[bool] = None, + workspace_id: Optional[int] = None, + department_ids: Optional[List[int]] = None, + article_type: Optional[int] = None, + status: Optional[int] = None, + tags: Optional[List[str]] = None, + keywords: Optional[List[str]] = None, + review_date: Optional[str] = None, + ) -> Dict[str, Any]: + """Unified solution operations for categories, folders, and articles. + + Args: + action: One of: + Categories: 'list_categories', 'get_category', 'create_category', 'update_category' + Folders: 'list_folders', 'get_folder', 'create_folder', 'update_folder' + Articles: 'list_articles', 'get_article', 'create_article', + 'update_article', 'publish_article' + category_id: Category ID (get/update category, list folders, create folder) + folder_id: Folder ID (get/update folder, list/create articles) + article_id: Article ID (get/update/publish article) + name: Name (create/update category or folder) + title: Article title (create/update article) + description: Description text/HTML + visibility: Folder visibility (1=all, 2=logged-in, 3=agents, 4=depts) + default_category: Mark as default (update_category) + workspace_id: Workspace ID (create/update category) + department_ids: Department IDs (create folder) + article_type: 1=permanent, 2=workaround (create/update article) + status: 1=draft, 2=published (create/update article) + tags: Article tags list + keywords: SEO keywords list + review_date: ISO date for article review + """ + action = action.lower().strip() + + # ── Categories ── + if action == "list_categories": + try: + resp = await api_get("solutions/categories") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list solution categories") + + if action == "get_category": + if not category_id: + return {"error": "category_id required for get_category"} + try: + resp = await api_get(f"solutions/categories/{category_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get solution category") + + if action == "create_category": + if not name: + return {"error": "name required for create_category"} + data: Dict[str, Any] = {"name": name} + if description: + data["description"] = description + if workspace_id is not None: + data["workspace_id"] = workspace_id + try: + resp = await api_post("solutions/categories", json=data) + resp.raise_for_status() + return {"success": True, "category": resp.json()} + except Exception as e: + return handle_error(e, "create solution category") + + if action == "update_category": + if not category_id: + return {"error": "category_id required for update_category"} + data: Dict[str, Any] = {} + for k, v in [("name", name), ("description", description), + ("workspace_id", workspace_id), + ("default_category", default_category)]: + if v is not None: + data[k] = v + if not data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"solutions/categories/{category_id}", json=data) + resp.raise_for_status() + return {"success": True, "category": resp.json()} + except Exception as e: + return handle_error(e, "update solution category") + + # ── Folders ── + if action == "list_folders": + if not category_id: + return {"error": "category_id required for list_folders"} + try: + resp = await api_get("solutions/folders", params={"category_id": category_id}) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list solution folders") + + if action == "get_folder": + if not folder_id: + return {"error": "folder_id required for get_folder"} + try: + resp = await api_get(f"solutions/folders/{folder_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get solution folder") + + if action == "create_folder": + if not name or not category_id or not department_ids: + return {"error": "name, category_id and department_ids required for create_folder"} + data: Dict[str, Any] = { + "name": name, + "category_id": category_id, + "department_ids": department_ids, + "visibility": visibility or 4, + } + if description: + data["description"] = description + try: + resp = await api_post("solutions/folders", json=data) + resp.raise_for_status() + return {"success": True, "folder": resp.json()} + except Exception as e: + return handle_error(e, "create solution folder") + + if action == "update_folder": + if not folder_id: + return {"error": "folder_id required for update_folder"} + data: Dict[str, Any] = {} + for k, v in [("name", name), ("description", description), + ("visibility", visibility)]: + if v is not None: + data[k] = v + if not data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"solutions/folders/{folder_id}", json=data) + resp.raise_for_status() + return {"success": True, "folder": resp.json()} + except Exception as e: + return handle_error(e, "update solution folder") + + # ── Articles ── + if action == "list_articles": + if not folder_id: + return {"error": "folder_id required for list_articles"} + try: + resp = await api_get("solutions/articles", params={"folder_id": folder_id}) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list solution articles") + + if action == "get_article": + if not article_id: + return {"error": "article_id required for get_article"} + try: + resp = await api_get(f"solutions/articles/{article_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get solution article") + + if action == "create_article": + if not title or not description or not folder_id: + return {"error": "title, description and folder_id required for create_article"} + data: Dict[str, Any] = { + "title": title, + "description": description, + "folder_id": folder_id, + "article_type": article_type or 1, + "status": status or 1, + } + for k, v in [("tags", tags), ("keywords", keywords), + ("review_date", review_date)]: + if v is not None: + data[k] = v + try: + resp = await api_post("solutions/articles", json=data) + resp.raise_for_status() + return {"success": True, "article": resp.json()} + except Exception as e: + return handle_error(e, "create solution article") + + if action == "update_article": + if not article_id: + return {"error": "article_id required for update_article"} + data: Dict[str, Any] = {} + for k, v in [("title", title), ("description", description), + ("folder_id", folder_id), ("article_type", article_type), + ("status", status), ("tags", tags), ("keywords", keywords), + ("review_date", review_date)]: + if v is not None: + data[k] = v + if not data: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"solutions/articles/{article_id}", json=data) + resp.raise_for_status() + return {"success": True, "article": resp.json()} + except Exception as e: + return handle_error(e, "update solution article") + + if action == "publish_article": + if not article_id: + return {"error": "article_id required for publish_article"} + try: + resp = await api_put(f"solutions/articles/{article_id}", json={"status": 2}) + resp.raise_for_status() + return {"success": True, "article": resp.json()} + except Exception as e: + return handle_error(e, "publish solution article") + + return {"error": f"Unknown action '{action}'. Valid: list_categories, get_category, create_category, update_category, list_folders, get_folder, create_folder, update_folder, list_articles, get_article, create_article, update_article, publish_article"} diff --git a/src/freshservice_mcp/tools/tickets.py b/src/freshservice_mcp/tools/tickets.py new file mode 100644 index 0000000..2ff68ab --- /dev/null +++ b/src/freshservice_mcp/tools/tickets.py @@ -0,0 +1,372 @@ +"""Freshservice MCP — Tickets tools (consolidated). + +Exposes 3 tools instead of the original 11: + • manage_ticket — CRUD + list + filter + get_fields + • manage_ticket_conversation — reply, add_note, update, list + • manage_service_catalog — list_items, get_requested_items, place_request +""" +import json +import urllib.parse +from typing import Any, Dict, List, Optional, Union + +import httpx + +from ..config import ( + FRESHSERVICE_DOMAIN, + TicketPriority, + TicketSource, + TicketStatus, +) +from ..http_client import ( + api_delete, + api_get, + api_post, + api_put, + api_url, + get_auth_headers, + handle_error, + parse_link_header, +) + + +# ── helpers ──────────────────────────────────────────────────────────────── +def _validate_pagination(page: int, per_page: int) -> Optional[Dict[str, Any]]: + if page < 1: + return {"error": "Page number must be greater than 0"} + if per_page < 1 or per_page > 100: + return {"error": "Page size must be between 1 and 100"} + return None + + +# ── registration ─────────────────────────────────────────────────────────── +def register_tickets_tools(mcp) -> None: + """Register ticket-related tools on *mcp*.""" + + # ------------------------------------------------------------------ # + # manage_ticket # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_ticket( + action: str, + ticket_id: Optional[int] = None, + # create / update fields + subject: Optional[str] = None, + description: Optional[str] = None, + source: Optional[Union[int, str]] = None, + priority: Optional[Union[int, str]] = None, + status: Optional[Union[int, str]] = None, + email: Optional[str] = None, + requester_id: Optional[int] = None, + custom_fields: Optional[Dict[str, Any]] = None, + ticket_fields: Optional[Dict[str, Any]] = None, + # filter / list + query: Optional[str] = None, + page: int = 1, + per_page: int = 30, + workspace_id: Optional[int] = None, + ) -> Dict[str, Any]: + """Unified ticket operations. + + Args: + action: One of 'create', 'update', 'delete', 'get', 'list', 'filter', 'get_fields' + ticket_id: Required for get, update, delete + subject: Ticket subject (create) + description: Ticket body — HTML (create) + source: Source enum (1=Email,2=Portal,3=Phone…) (create) + priority: 1=Low,2=Medium,3=High,4=Urgent (create/update) + status: 2=Open,3=Pending,4=Resolved,5=Closed (create/update) + email: Requester email (create — required if no requester_id) + requester_id: Requester ID (create — required if no email) + custom_fields: Key-value custom field pairs + ticket_fields: Dict of fields to update (update action) + query: Filter query string, e.g. "priority:3 AND status:2" (filter) + page: Page number (list/filter) + per_page: Items per page 1-100 (list) + workspace_id: Workspace filter (filter) + """ + action = action.lower().strip() + + # ---------- get_fields ---------- + if action == "get_fields": + try: + resp = await api_get("ticket_form_fields") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "fetch ticket fields") + + # ---------- list ---------- + if action == "list": + err = _validate_pagination(page, per_page) + if err: + return err + try: + resp = await api_get("tickets", params={"page": page, "per_page": per_page}) + resp.raise_for_status() + pagination_info = parse_link_header(resp.headers.get("Link", "")) + return { + "tickets": resp.json(), + "pagination": { + "current_page": page, + "next_page": pagination_info.get("next"), + "prev_page": pagination_info.get("prev"), + "per_page": per_page, + }, + } + except Exception as e: + return handle_error(e, "list tickets") + + # ---------- filter ---------- + if action == "filter": + if not query: + return {"error": "query is required for filter action"} + encoded_query = urllib.parse.quote(f'"{query}"') + url = f"tickets/filter?query={encoded_query}&page={page}" + if workspace_id is not None: + url += f"&workspace_id={workspace_id}" + try: + resp = await api_get(url) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "filter tickets") + + # ---------- get ---------- + if action == "get": + if not ticket_id: + return {"error": "ticket_id is required for get action"} + try: + resp = await api_get(f"tickets/{ticket_id}") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get ticket") + + # ---------- create ---------- + if action == "create": + if not subject or not description: + return {"error": "subject and description are required for create action"} + if not email and not requester_id: + return {"error": "Either email or requester_id must be provided"} + try: + source_val = int(source) if source else TicketSource.PORTAL.value + priority_val = int(priority) if priority else TicketPriority.LOW.value + status_val = int(status) if status else TicketStatus.OPEN.value + except ValueError: + return {"error": "Invalid value for source, priority, or status"} + + data: Dict[str, Any] = { + "subject": subject, + "description": description, + "source": source_val, + "priority": priority_val, + "status": status_val, + } + if email: + data["email"] = email + if requester_id: + data["requester_id"] = requester_id + if custom_fields: + data["custom_fields"] = custom_fields + + try: + resp = await api_post("tickets", json=data) + resp.raise_for_status() + return {"success": True, "ticket": resp.json()} + except Exception as e: + return handle_error(e, "create ticket") + + # ---------- update ---------- + if action == "update": + if not ticket_id: + return {"error": "ticket_id is required for update action"} + fields = ticket_fields or {} + if priority is not None: + fields["priority"] = int(priority) + if status is not None: + fields["status"] = int(status) + if subject is not None: + fields["subject"] = subject + if description is not None: + fields["description"] = description + if custom_fields: + fields["custom_fields"] = custom_fields + if not fields: + return {"error": "No fields provided for update"} + try: + resp = await api_put(f"tickets/{ticket_id}", json=fields) + resp.raise_for_status() + return {"success": True, "ticket": resp.json()} + except Exception as e: + return handle_error(e, "update ticket") + + # ---------- delete ---------- + if action == "delete": + if not ticket_id: + return {"error": "ticket_id is required for delete action"} + try: + resp = await api_delete(f"tickets/{ticket_id}") + if resp.status_code == 204: + return {"success": True, "message": "Ticket deleted successfully"} + return {"error": f"Unexpected status {resp.status_code}"} + except Exception as e: + return handle_error(e, "delete ticket") + + return {"error": f"Unknown action '{action}'. Valid: create, update, delete, get, list, filter, get_fields"} + + # ------------------------------------------------------------------ # + # manage_ticket_conversation # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_ticket_conversation( + action: str, + ticket_id: Optional[int] = None, + conversation_id: Optional[int] = None, + body: Optional[str] = None, + from_email: Optional[str] = None, + user_id: Optional[int] = None, + cc_emails: Optional[List[str]] = None, + bcc_emails: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Manage ticket conversations — replies, notes, updates. + + Args: + action: 'reply', 'add_note', 'update', 'list' + ticket_id: Required for reply, add_note, list + conversation_id: Required for update + body: HTML body content (reply, add_note, update) + from_email: Sender email (reply) + user_id: Agent user ID (reply) + cc_emails: CC email list (reply) + bcc_emails: BCC email list (reply) + """ + action = action.lower().strip() + + if action == "list": + if not ticket_id: + return {"error": "ticket_id required"} + try: + resp = await api_get(f"tickets/{ticket_id}/conversations") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "list conversations") + + if action == "reply": + if not ticket_id or not body: + return {"error": "ticket_id and body required for reply"} + payload: Dict[str, Any] = { + "body": body.strip(), + "from_email": from_email or f"helpdesk@{FRESHSERVICE_DOMAIN}", + } + if user_id is not None: + payload["user_id"] = user_id + if cc_emails: + payload["cc_emails"] = cc_emails + if bcc_emails: + payload["bcc_emails"] = bcc_emails + try: + resp = await api_post(f"tickets/{ticket_id}/reply", json=payload) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "reply to ticket") + + if action == "add_note": + if not ticket_id or not body: + return {"error": "ticket_id and body required for add_note"} + try: + resp = await api_post(f"tickets/{ticket_id}/notes", json={"body": body}) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "add ticket note") + + if action == "update": + if not conversation_id or not body: + return {"error": "conversation_id and body required for update"} + try: + resp = await api_put(f"conversations/{conversation_id}", json={"body": body}) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "update conversation") + + return {"error": f"Unknown action '{action}'. Valid: reply, add_note, update, list"} + + # ------------------------------------------------------------------ # + # manage_service_catalog # + # ------------------------------------------------------------------ # + @mcp.tool() + async def manage_service_catalog( + action: str, + ticket_id: Optional[int] = None, + display_id: Optional[int] = None, + email: Optional[str] = None, + requested_for: Optional[str] = None, + quantity: int = 1, + page: int = 1, + per_page: int = 30, + ) -> Dict[str, Any]: + """Service catalog operations. + + Args: + action: 'list_items', 'get_requested_items', 'place_request' + ticket_id: Ticket ID (get_requested_items) + display_id: Service item display ID (place_request) + email: Requester email (place_request) + requested_for: Email of person for whom request is placed (place_request) + quantity: Number of items (place_request, default 1) + page: Page number (list_items) + per_page: Items per page (list_items) + """ + action = action.lower().strip() + + if action == "list_items": + err = _validate_pagination(page, per_page) + if err: + return err + all_items: List[Any] = [] + current_page = page + try: + while True: + resp = await api_get("service_catalog/items", params={"page": current_page, "per_page": per_page}) + resp.raise_for_status() + all_items.append(resp.json()) + pagination_info = parse_link_header(resp.headers.get("Link", "")) + if not pagination_info.get("next"): + break + current_page = pagination_info["next"] + return {"success": True, "items": all_items} + except Exception as e: + return handle_error(e, "list service items") + + if action == "get_requested_items": + if not ticket_id: + return {"error": "ticket_id required"} + try: + # verify it's a service request first + ticket_resp = await api_get(f"tickets/{ticket_id}") + ticket_resp.raise_for_status() + if ticket_resp.json().get("ticket", {}).get("type") != "Service Request": + return {"error": "Requested items can only be fetched for service requests"} + resp = await api_get(f"tickets/{ticket_id}/requested_items") + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "get requested items") + + if action == "place_request": + if not display_id or not email: + return {"error": "display_id and email required for place_request"} + payload = {"email": email, "quantity": quantity} + if requested_for: + payload["requested_for"] = requested_for + try: + resp = await api_post(f"service_catalog/items/{display_id}/place_request", json=payload) + resp.raise_for_status() + return resp.json() + except Exception as e: + return handle_error(e, "place service request") + + return {"error": f"Unknown action '{action}'. Valid: list_items, get_requested_items, place_request"}