From e07542176dd252715816557e8f991a63660d8503 Mon Sep 17 00:00:00 2001 From: Vinicius Ribeiro Date: Tue, 17 Mar 2026 12:56:29 -0500 Subject: [PATCH 1/5] fix: return JSON strings from all MCP tool functions All @mcp.tool() functions were returning Python dicts, but the MCP SDK's Pydantic output validation expects string results. This caused errors like: 1 validation error for get_ticket_by_idOutput result Input should be a valid string Changes: - Changed all return type annotations from Dict[str, Any] to str - Wrapped all response.json() returns with json.dumps() - Wrapped all dict literal returns with json.dumps() - Added proper try/except error handling to 18 functions that had none --- src/freshservice_mcp/server.py | 1323 +++++++++++++++++--------------- 1 file changed, 688 insertions(+), 635 deletions(-) diff --git a/src/freshservice_mcp/server.py b/src/freshservice_mcp/server.py index b27c96c..1046f30 100644 --- a/src/freshservice_mcp/server.py +++ b/src/freshservice_mcp/server.py @@ -166,45 +166,51 @@ def parse_link_header(link_header: str) -> Dict[str, Optional[int]]: #GET TICKET FIELDS @mcp.tool() -async def get_ticket_fields() -> Dict[str, Any]: +async def get_ticket_fields() -> str: """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() + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Failed to fetch ticket fields: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #GET TICKETS @mcp.tool() -async def get_tickets(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: +async def get_tickets(page: Optional[int] = 1, per_page: Optional[int] = 30) -> str: """Get tickets from Freshservice with pagination support.""" - + if page < 1: - return {"error": "Page number must be greater than 0"} - + return json.dumps({"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 json.dumps({"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 { + + return json.dumps({ "tickets": tickets, "pagination": { "current_page": page, @@ -212,12 +218,12 @@ async def get_tickets(page: Optional[int] = 1, per_page: Optional[int] = 30) -> "prev_page": pagination_info.get("prev"), "per_page": per_page } - } - + }) + except httpx.HTTPStatusError as e: - return {"error": f"Failed to fetch tickets: {str(e)}"} + return json.dumps({"error": f"Failed to fetch tickets: {str(e)}"}) except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #CREATE TICKET @mcp.tool() @@ -286,21 +292,21 @@ async def create_ticket( #UPDATE TICKET @mcp.tool() -async def update_ticket(ticket_id: int, ticket_fields: Dict[str, Any]) -> Dict[str, Any]: +async def update_ticket(ticket_id: int, ticket_fields: Dict[str, Any]) -> str: """Update a ticket in Freshservice.""" if not ticket_fields: - return {"error": "No fields provided for update"} + return json.dumps({"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 @@ -308,13 +314,13 @@ async def update_ticket(ticket_id: int, ticket_fields: Dict[str, Any]) -> Dict[s try: response = await client.put(url, headers=headers, json=update_data) response.raise_for_status() - - return { + + return json.dumps({ "success": True, "message": "Ticket updated successfully", "ticket": response.json() - } - + }) + except httpx.HTTPStatusError as e: error_message = f"Failed to update ticket: {str(e)}" try: @@ -323,19 +329,19 @@ async def update_ticket(ticket_id: int, ticket_fields: Dict[str, Any]) -> Dict[s error_message = f"Validation errors: {error_details['errors']}" except Exception: pass - return { + return json.dumps({ "success": False, "error": error_message - } + }) except Exception as e: - return { + return json.dumps({ "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]: +async def filter_tickets(query: str, page: int = 1, workspace_id: Optional[int] = None) -> str: """Filter the tickets in Freshservice. Args: @@ -348,7 +354,7 @@ async def filter_tickets(query: str, page: int = 1, workspace_id: Optional[int] # 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}" @@ -358,12 +364,12 @@ async def filter_tickets(query: str, page: int = 1, workspace_id: Optional[int] try: response = await client.get(url, headers=headers) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) #DELETE TICKET. @mcp.tool() @@ -389,14 +395,20 @@ async def delete_ticket(ticket_id: int) -> str: #GET TICKET BY ID @mcp.tool() -async def get_ticket_by_id(ticket_id:int) -> Dict[str, Any]: +async def get_ticket_by_id(ticket_id:int) -> str: """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() + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Failed to fetch ticket: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #GET ALL CHANGES @mcp.tool() @@ -409,9 +421,9 @@ async def get_changes( order_by: Optional[str] = None, updated_since: Optional[str] = None, workspace_id: Optional[int] = None -) -> Dict[str, Any]: +) -> str: """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) @@ -423,7 +435,7 @@ async def get_changes( 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 @@ -431,23 +443,23 @@ async def get_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"} - + return json.dumps({"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 json.dumps({"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: @@ -460,20 +472,20 @@ async def get_changes( 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 { + + return json.dumps({ "changes": changes, "pagination": { "current_page": page, @@ -481,19 +493,19 @@ async def get_changes( "prev_page": pagination_info.get("prev"), "per_page": per_page } - } - + }) + except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} + return json.dumps({"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]: +async def get_change_by_id(change_id: int) -> str: """Get a specific change by ID in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" headers = get_auth_headers() @@ -502,11 +514,11 @@ async def get_change_by_id(change_id: int) -> Dict[str, Any]: try: response = await client.get(url, headers=headers) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: - return {"error": f"Failed to fetch change: {str(e)}"} + return json.dumps({"error": f"Failed to fetch change: {str(e)}"}) except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #CREATE CHANGE @mcp.tool() @@ -529,9 +541,9 @@ async def create_change( rollout_plan: Optional[str] = None, backout_plan: Optional[str] = None, custom_fields: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: +) -> str: """Create a new change in Freshservice.""" - + try: priority_val = int(priority) impact_val = int(impact) @@ -539,14 +551,14 @@ async def create_change( risk_val = int(risk) change_type_val = int(change_type) except ValueError: - return {"error": "Invalid value for priority, impact, status, risk, or change_type"} + return json.dumps({"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"} + return json.dumps({"error": "Invalid value for priority, impact, status, risk, or change_type"}) data = { "requester_id": requester_id, @@ -602,21 +614,21 @@ async def create_change( try: response = await client.post(url, headers=headers, json=data) response.raise_for_status() - return response.json() + return json.dumps(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)}"} + return json.dumps({"error": f"Validation Error: {error_data['errors']}"}) + return json.dumps({"error": f"Failed to create change - {str(e)}"}) except Exception as e: - return {"error": f"An unexpected error occurred - {str(e)}"} + return json.dumps({"error": f"An unexpected error occurred - {str(e)}"}) #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, change_fields: Dict[str, Any]) -> str: + """Update an existing change in Freshservice. + To update the change result explanation when closing a change: change_fields = { "status": 6, # Closed @@ -626,7 +638,7 @@ async def update_change(change_id: int, change_fields: Dict[str, Any]) -> Dict[s } """ if not change_fields: - return {"error": "No fields provided for update"} + return json.dumps({"error": "No fields provided for update"}) url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}" headers = get_auth_headers() @@ -659,13 +671,13 @@ async def update_change(change_id: int, change_fields: Dict[str, Any]) -> Dict[s try: response = await client.put(url, headers=headers, json=update_data) response.raise_for_status() - - return { + + return json.dumps({ "success": True, "message": "Change updated successfully", "change": response.json() - } - + }) + except httpx.HTTPStatusError as e: error_message = f"Failed to update change: {str(e)}" try: @@ -674,15 +686,15 @@ async def update_change(change_id: int, change_fields: Dict[str, Any]) -> Dict[s error_message = f"Validation errors: {error_details['errors']}" except Exception: pass - return { + return json.dumps({ "success": False, "error": error_message - } + }) except Exception as e: - return { + return json.dumps({ "success": False, "error": f"An unexpected error occurred: {str(e)}" - } + }) #CLOSE CHANGE WITH RESULT @mcp.tool() @@ -690,7 +702,7 @@ async def close_change( change_id: int, change_result_explanation: str, custom_fields: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: +) -> str: """Close a change and provide the result explanation. This is a convenience function that updates status to Closed and sets the result explanation.""" @@ -738,9 +750,9 @@ async def filter_changes( sort: Optional[str] = None, order_by: Optional[str] = None, workspace_id: Optional[int] = None -) -> Dict[str, Any]: +) -> str: """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! @@ -751,7 +763,7 @@ async def filter_changes( 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) @@ -773,7 +785,7 @@ async def filter_changes( #GET CHANGE TASKS @mcp.tool() -async def get_change_tasks(change_id: int) -> Dict[str, Any]: +async def get_change_tasks(change_id: int) -> str: """Get all tasks associated with a change.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/tasks" headers = get_auth_headers() @@ -782,15 +794,15 @@ async def get_change_tasks(change_id: int) -> Dict[str, Any]: try: response = await client.get(url, headers=headers) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: - return {"error": f"Failed to fetch change tasks: {str(e)}"} + return json.dumps({"error": f"Failed to fetch change tasks: {str(e)}"}) except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} + return json.dumps({"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]: +async def create_change_note(change_id: int, body: str) -> str: """Create a note for a change in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/changes/{change_id}/notes" headers = get_auth_headers() @@ -801,11 +813,11 @@ async def create_change_note(change_id: int, body: str) -> Dict[str, Any]: try: response = await client.post(url, headers=headers, json=data) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: - return {"error": f"Failed to create change note: {str(e)}"} + return json.dumps({"error": f"Failed to create change note: {str(e)}"}) except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) # CHANGES APPROVAL ENDPOINTS @@ -816,9 +828,9 @@ async def create_change_approval_group( name: str, approver_ids: List[int], approval_type: str = "everyone" -) -> Dict[str, Any]: +) -> str: """Create an approval group for a change. - + Args: change_id: The ID of the change name: Name of the approval group @@ -832,17 +844,17 @@ async def create_change_approval_group( "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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) #UPDATE CHANGE APPROVAL GROUP @mcp.tool() @@ -852,11 +864,11 @@ async def update_change_approval_group( name: Optional[str] = None, approver_ids: Optional[List[int]] = None, approval_type: Optional[str] = None -) -> Dict[str, Any]: +) -> str: """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 @@ -864,232 +876,232 @@ async def update_change_approval_group( 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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def cancel_change_approval_group(change_id: int, group_id: int) -> str: """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"} + return json.dumps({"success": True, "message": "Approval group cancelled successfully"}) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +) -> str: """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'"} - + return json.dumps({"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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def list_change_approval_groups(change_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def view_change_approval(change_id: int, approval_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) #LIST CHANGE APPROVALS @mcp.tool() -async def list_change_approvals(change_id: int) -> Dict[str, Any]: +async def list_change_approvals(change_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def send_change_approval_reminder(change_id: int, approval_id: int) -> str: """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"} + return json.dumps({"success": True, "message": "Reminder sent successfully"}) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def cancel_change_approval(change_id: int, approval_id: int) -> str: """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"} + return json.dumps({"success": True, "message": "Approval cancelled successfully"}) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def view_change_note(change_id: int, note_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) #LIST CHANGE NOTES @mcp.tool() -async def list_change_notes(change_id: int) -> Dict[str, Any]: +async def list_change_notes(change_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def update_change_note(change_id: int, note_id: int, body: str) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def delete_change_note(change_id: int, note_id: int) -> str: """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"} + return json.dumps({"success": True, "message": "Note deleted successfully"}) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) # CHANGES TASKS ENDPOINTS @@ -1104,53 +1116,53 @@ async def create_change_task( assigned_to_id: Optional[int] = None, group_id: Optional[int] = None, due_date: Optional[str] = None -) -> Dict[str, Any]: +) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def view_change_task(change_id: int, task_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) #UPDATE CHANGE TASK @mcp.tool() @@ -1158,41 +1170,41 @@ async def update_change_task( change_id: int, task_id: int, task_fields: Dict[str, Any] -) -> Dict[str, Any]: +) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def delete_change_task(change_id: int, task_id: int) -> str: """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"} + return json.dumps({"success": True, "message": "Task deleted successfully"}) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) # CHANGES TIME ENTRIES ENDPOINTS @@ -1204,9 +1216,9 @@ async def create_change_time_entry( note: str, agent_id: int, executed_at: Optional[str] = None -) -> Dict[str, Any]: +) -> str: """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") @@ -1216,62 +1228,62 @@ async def create_change_time_entry( """ 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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def view_change_time_entry(change_id: int, time_entry_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def list_change_time_entries(change_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) #UPDATE CHANGE TIME ENTRY @mcp.tool() @@ -1280,97 +1292,97 @@ async def update_change_time_entry( time_entry_id: int, time_spent: Optional[str] = None, note: Optional[str] = None -) -> Dict[str, Any]: +) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def delete_change_time_entry(change_id: int, time_entry_id: int) -> str: """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"} + return json.dumps({"success": True, "message": "Time entry deleted successfully"}) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def move_change(change_id: int, workspace_id: int) -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"error": str(e), "raw_response": e.response.text}) #LIST CHANGE FIELDS @mcp.tool() -async def list_change_fields() -> Dict[str, Any]: +async def list_change_fields() -> str: """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() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: try: - return {"error": str(e), "details": e.response.json()} + return json.dumps({"error": str(e), "details": e.response.json()}) except Exception: - return {"error": str(e), "raw_response": e.response.text} + return json.dumps({"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]: +async def list_service_items(page: Optional[int] = 1, per_page: Optional[int] = 30) -> str: """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"} + return json.dumps({"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 json.dumps({"error": "Page size must be between 1 and 100"}) headers = get_auth_headers() all_items: List[Dict[str, Any]] = [] @@ -1399,11 +1411,11 @@ async def list_service_items(page: Optional[int] = 1, per_page: Optional[int] = current_page = pagination_info["next"] except httpx.HTTPStatusError as e: - return {"error": f"HTTP error occurred: {str(e)}"} + return json.dumps({"error": f"HTTP error occurred: {str(e)}"}) except Exception as e: - return {"error": f"Unexpected error: {str(e)}"} + return json.dumps({"error": f"Unexpected error: {str(e)}"}) - return { + return json.dumps({ "success": True, "items": all_items, "pagination": { @@ -1411,31 +1423,31 @@ async def list_service_items(page: Optional[int] = 1, per_page: Optional[int] = "per_page": per_page, "last_fetched_page": current_page } - } + }) #GET REQUESTED ITEMS @mcp.tool() -async def get_requested_items(ticket_id: int) -> dict: +async def get_requested_items(ticket_id: int) -> str: """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() + headers = get_auth_headers() async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + 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: @@ -1443,31 +1455,31 @@ async def get_ticket(ticket_id: int) -> dict: # 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 - + return json.dumps(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 + headers = get_auth_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 + response.raise_for_status() # If the response contains requested items, return them if response.status_code == 200: - return response.json() + return json.dumps(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)}"} + return json.dumps({"success": False, "error": "There are no service items for this ticket"}) + return json.dumps({"success": False, "error": f"HTTP error occurred: {str(e)}"}) except Exception as e: - return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + return json.dumps({"success": False, "error": f"An unexpected error occurred: {str(e)}"}) #CREATE SERVICE REQUEST @mcp.tool() @@ -1476,13 +1488,13 @@ async def create_service_request( email: str, requested_for: Optional[str] = None, quantity: int = 1 -) -> dict: +) -> str: """Create a service request in Freshservice.""" if not isinstance(quantity, int) or quantity <= 0: - return {"success": False, "error": "Quantity must be a positive integer."} + return json.dumps({"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."} + return json.dumps({"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" @@ -1500,16 +1512,16 @@ async def create_service_request( try: response = await client.post(url, headers=headers, json=payload) response.raise_for_status() - return response.json() + return json.dumps(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} + return json.dumps({"success": False, "error": error_details}) except Exception: - return {"success": False, "error": error_message} + return json.dumps({"success": False, "error": error_message}) except Exception as e: - return {"success": False, "error": str(e)} + return json.dumps({"success": False, "error": str(e)}) #SEND TICKET REPLY @mcp.tool() @@ -1520,16 +1532,16 @@ async def send_ticket_reply( user_id: Optional[int] = None, cc_emails: Optional[Union[str, List[str]]] = None, bcc_emails: Optional[Union[str, List[str]]] = None -) -> dict: +) -> str: """ 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"} + return json.dumps({"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"} + return json.dumps({"success": False, "error": "Missing or empty body: Reply content is required"}) def parse_emails(value): if isinstance(value, str): @@ -1563,15 +1575,15 @@ def parse_emails(value): try: response = await client.post(url, json=payload, headers=headers) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: - return {"success": False, "error": f"HTTP error occurred: {str(e)}"} + return json.dumps({"success": False, "error": f"HTTP error occurred: {str(e)}"}) except Exception as e: - return {"success": False, "error": f"An unexpected error occurred: {str(e)}"} + return json.dumps({"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]: +async def create_ticket_note(ticket_id: int,body: str)-> str: """Create a note for a ticket in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/notes" headers = get_auth_headers() @@ -1579,14 +1591,20 @@ async def create_ticket_note(ticket_id: int,body: str)-> Dict[str, Any]: "body": body } async with httpx.AsyncClient() as client: - response = await client.post(url, headers=headers, json=data) - return response.json() + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Failed to create ticket note: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #UPDATE A CONVERSATION #UPDATE TICKET CONVERSATION @mcp.tool() -async def update_ticket_conversation(conversation_id: int,body: str)-> Dict[str, Any]: +async def update_ticket_conversation(conversation_id: int,body: str)-> str: """Update a conversation for a ticket in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/conversations/{conversation_id}" headers = get_auth_headers() @@ -1594,37 +1612,41 @@ async def update_ticket_conversation(conversation_id: int,body: str)-> Dict[str, "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()}" + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot update conversation: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #GET ALL TICKET CONVERSATION @mcp.tool() -async def list_all_ticket_conversation(ticket_id: int)-> Dict[str, Any]: +async def list_all_ticket_conversation(ticket_id: int)-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch ticket conversations: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #GET ALL PRODUCTS @mcp.tool() -async def get_all_products(page: Optional[int] = 1, per_page: Optional[int] = 30) -> Dict[str, Any]: +async def get_all_products(page: Optional[int] = 1, per_page: Optional[int] = 30) -> str: """List all the products from Freshservice.""" if page < 1: - return {"error": "Page number must be greater than 0"} - + return json.dumps({"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 json.dumps({"error": "Page size must be between 1 and 100"}) url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/products" headers = get_auth_headers() @@ -1646,7 +1668,7 @@ async def get_all_products(page: Optional[int] = 1, per_page: Optional[int] = 30 pagination_info = parse_link_header(link_header) next_page = pagination_info.get("next") - return { + return json.dumps({ "success": True, "products": products, "pagination": { @@ -1655,27 +1677,29 @@ async def get_all_products(page: Optional[int] = 1, per_page: Optional[int] = 30 "has_next": bool(next_page), "per_page": per_page } - } + }) except httpx.HTTPStatusError as e: - return {"success": False, "error": f"HTTP error occurred: {str(e)}"} + return json.dumps({"success": False, "error": f"HTTP error occurred: {str(e)}"}) except Exception as e: - return {"success": False, "error": f"Unexpected error occurred: {str(e)}"} + return json.dumps({"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]: +async def get_products_by_id(product_id:int)-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch products from freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #CREATE PRODUCT @mcp.tool() @@ -1688,7 +1712,7 @@ async def create_product( depreciation_type_id: Optional[int] = None, description: Optional[str] = None, description_text: Optional[str] = None -) -> Dict[str, Any]: +) -> str: """Create a product in Freshservice.""" # Allowed statuses mapping @@ -1704,13 +1728,13 @@ async def create_product( # Validate status if status is not None: if status not in allowed_statuses: - return { + return json.dumps({ "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" @@ -1738,21 +1762,21 @@ async def create_product( try: response = await client.post(url, headers=headers, json=payload) response.raise_for_status() - return {"success": True, "data": response.json()} + return json.dumps({"success": True, "data": response.json()}) except httpx.HTTPStatusError as http_err: - return { + return json.dumps({ "success": False, "status_code": response.status_code, "error": f"HTTP error occurred: {http_err}", "response": response.json() - } + }) except Exception as err: - return { + return json.dumps({ "success": False, "error": f"An unexpected error occurred: {err}" - } + }) -#UPDATE PRODUCT +#UPDATE PRODUCT @mcp.tool() async def update_product( id: int, @@ -1764,7 +1788,7 @@ async def update_product( depreciation_type_id: Optional[int] = None, description: Optional[str] = None, description_text: Optional[str] = None -) -> Dict[str, Any]: +) -> str: """Update a product in Freshservice.""" allowed_statuses = { @@ -1778,13 +1802,13 @@ async def update_product( if status is not None: if status not in allowed_statuses: - return { + return json.dumps({ "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}" @@ -1813,19 +1837,19 @@ async def update_product( try: response = await client.put(url, headers=headers, json=payload) response.raise_for_status() - return {"success": True, "data": response.json()} + return json.dumps({"success": True, "data": response.json()}) except httpx.HTTPStatusError as http_err: - return { + return json.dumps({ "success": False, "status_code": response.status_code, "error": f"HTTP error occurred: {http_err}", "response": response.json() - } + }) except Exception as err: - return { + return json.dumps({ "success": False, "error": f"Unexpected error occurred: {err}" - } + }) #CREATE REQUESTER @mcp.tool() @@ -1847,17 +1871,17 @@ async def create_requester( location_id: Optional[int] = None, background_information: Optional[str] = None, custom_fields: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: +) -> str: """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."} + return json.dumps({"success": False, "error": "'first_name' must be a non-empty string."}) if not (primary_email or work_phone_number or mobile_phone_number): - return { + return json.dumps({ "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() @@ -1892,30 +1916,30 @@ async def create_requester( try: response = await client.post(url, headers=headers, json=payload) response.raise_for_status() - return {"success": True, "data": response.json()} + return json.dumps({"success": True, "data": response.json()}) except httpx.HTTPStatusError as http_err: - return { + return json.dumps({ "success": False, "status_code": response.status_code, "error": f"HTTP error: {http_err}", "response": response.json() - } + }) except Exception as err: - return { + return json.dumps({ "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]: +async def get_all_requesters(page: int = 1, per_page: int = 30) -> str: """Fetch all requesters from Freshservice.""" if page < 1: - return {"success": False, "error": "Page number must be greater than 0"} - + return json.dumps({"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"} + return json.dumps({"success": False, "error": "Page size must be between 1 and 100"}) url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters" headers = get_auth_headers() @@ -1932,7 +1956,7 @@ async def get_all_requesters(page: int = 1, per_page: int = 30) -> Dict[str, Any link_header = response.headers.get("Link", "") pagination_info = parse_link_header(link_header) - return { + return json.dumps({ "success": True, "requesters": requesters, "pagination": { @@ -1942,41 +1966,45 @@ async def get_all_requesters(page: int = 1, per_page: int = 30) -> Dict[str, Any "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)}"} + return json.dumps({"success": False, "error": f"HTTP error: {str(e)}"}) except Exception as e: - return {"success": False, "error": f"Unexpected error: {str(e)}"} + return json.dumps({"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]: +async def get_requester_id(requester_id:int)-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch requester from freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #LIST ALL REQUESTER FIELDS @mcp.tool() -async def list_all_requester_fields()-> Dict[str, Any]: +async def list_all_requester_fields()-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch requester fields from freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #UPDATE REQUESTER @mcp.tool() @@ -1999,7 +2027,7 @@ async def update_requester( location_id: Optional[int] = None, background_information: Optional[str] = None, custom_fields: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: +) -> str: """Update a requester in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requesters/{requester_id}" @@ -2028,33 +2056,39 @@ async def update_requester( 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} + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"success": False, "error": str(e), "status_code": e.response.status_code}) + except Exception as e: + return json.dumps({"success": False, "error": f"An unexpected error occurred: {str(e)}"}) #FILTER REQUESTERS @mcp.tool() -async def filter_requesters(query: str,include_agents: bool = False) -> Dict[str, Any]: +async def filter_requesters(query: str,include_agents: bool = False) -> str: """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 - } + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({ + "error": f"Failed to filter requesters: {e.response.status_code}", + "details": e.response.text + }) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #CREATE AN AGENT @mcp.tool() @@ -2066,9 +2100,9 @@ async def create_agent( job_title: Optional[str] = None, work_phone_number: Optional[int] = None, mobile_phone_number: Optional[int] = None, -) -> Dict[str, Any]: +) -> str: """Create a new agent in Freshservice.""" - + data = AgentInput( first_name=first_name, last_name=last_name, @@ -2083,40 +2117,45 @@ async def create_agent( 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() - } + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({ + "error": "Failed to create agent", + "status_code": e.response.status_code, + "details": e.response.json() + }) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #GET AN AGENT @mcp.tool() -async def get_agent(agent_id:int)-> Dict[str, Any]: +async def get_agent(agent_id:int)-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch agent from freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #GET ALL AGENTS @mcp.tool() -async def get_all_agents(page: int = 1, per_page: int = 30) -> Dict[str, Any]: +async def get_all_agents(page: int = 1, per_page: int = 30) -> str: """Fetch agents from Freshservice.""" if page < 1: - return {"success": False, "error": "Page number must be greater than 0"} + return json.dumps({"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"} + return json.dumps({"success": False, "error": "Page size must be between 1 and 100"}) url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents" headers = get_auth_headers() @@ -2134,7 +2173,7 @@ async def get_all_agents(page: int = 1, per_page: int = 30) -> Dict[str, Any]: link_header = response.headers.get("Link", "") pagination_info = parse_link_header(link_header) - return { + return json.dumps({ "success": True, "agents": agents, "pagination": { @@ -2144,7 +2183,7 @@ async def get_all_agents(page: int = 1, per_page: int = 30) -> Dict[str, Any]: "prev_page": pagination_info.get("prev"), "has_more": pagination_info.get("next") is not None } - } + }) except httpx.HTTPStatusError as e: error_text = None try: @@ -2152,15 +2191,15 @@ async def get_all_agents(page: int = 1, per_page: int = 30) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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]]: +async def filter_agents(query: str) -> str: """Filter Freshservice agents based on a query.""" base_url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents" headers = get_auth_headers() @@ -2170,29 +2209,34 @@ async def filter_agents(query: str) -> List[Dict[str, Any]]: 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() + try: + 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", [])) + data = response.json() + all_agents.extend(data.get("agents", [])) - link_header = response.headers.get("link") - pagination = parse_link_header(link_header) + link_header = response.headers.get("link") + pagination = parse_link_header(link_header) - if not pagination.get("next"): - break - page = pagination["next"] + if not pagination.get("next"): + break + page = pagination["next"] + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Failed to filter agents: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) - return all_agents + return json.dumps(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): +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) -> str: """Update the agent details in the Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/agents/{agent_id}" @@ -2216,74 +2260,82 @@ async def update_agent(agent_id, occasional=None, email=None, department_ids=Non 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()}" - + try: + response = await client.put(url, headers=headers, json=payload) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot update agent in freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) + #GET AGENT FIELDS @mcp.tool() -async def get_agent_fields()-> Dict[str, Any]: +async def get_agent_fields()-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch agent fields from freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #GET ALL AGENT GROUPS @mcp.tool() -async def get_all_agent_groups()-> Dict[str, Any]: +async def get_all_agent_groups()-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch agent groups from freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #GET AGENT GROUP BY ID @mcp.tool() -async def getAgentGroupById(group_id:int)-> Dict[str, Any]: +async def getAgentGroupById(group_id:int)-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch agent group from freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #ADD REQUESTER TO GROUP @mcp.tool() async def add_requester_to_group( group_id: int, requester_id: int -) -> Dict[str, Any]: +) -> str: """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() + headers = get_auth_headers() async with httpx.AsyncClient() as client: try: response = await client.post(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return {"success": f"Requester {requester_id} added to group {group_id}."} + return json.dumps({"success": f"Requester {requester_id} added to group {group_id}."}) except httpx.HTTPStatusError as e: error_text = None @@ -2292,18 +2344,18 @@ async def add_requester_to_group( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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]: +async def create_group(group_data: Dict[str, Any]) -> str: """Create a group in Freshservice.""" if "name" not in group_data: - return {"error": "Field 'name' is required to create a group."} + return json.dumps({"error": "Field 'name' is required to create a group."}) url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/groups" headers = get_auth_headers() @@ -2312,8 +2364,8 @@ async def create_group(group_data: Dict[str, Any]) -> Dict[str, Any]: try: response = await client.post(url, headers=headers, json=group_data) response.raise_for_status() - return response.json() - + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: error_text = None try: @@ -2321,29 +2373,29 @@ async def create_group(group_data: Dict[str, Any]) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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]: +async def update_group(group_id: int, group_fields: Dict[str, Any]) -> str: """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)}"} + return json.dumps({"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() - + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: error_text = None try: @@ -2351,21 +2403,21 @@ async def update_group(group_id: int, group_fields: Dict[str, Any]) -> Dict[str, except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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]: +async def get_all_requester_groups(page: Optional[int] = 1, per_page: Optional[int] = 30) -> str: """Get all requester groups in Freshservice.""" if page < 1: - return {"error": "Page number must be greater than 0"} - + return json.dumps({"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 json.dumps({"error": "Page size must be between 1 and 100"}) url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/requester_groups" headers = get_auth_headers() @@ -2386,7 +2438,7 @@ async def get_all_requester_groups(page: Optional[int] = 1, per_page: Optional[i data = response.json() - return { + return json.dumps({ "success": True, "requester_groups": data, "pagination": { @@ -2395,34 +2447,36 @@ async def get_all_requester_groups(page: Optional[int] = 1, per_page: Optional[i "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)}"} + return json.dumps({"error": f"Failed to fetch all requester groups: {str(e)}"}) except Exception as e: - return {"error": f"An unexpected error occurred: {str(e)}"} - + return json.dumps({"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]: +async def get_requester_groups_by_id(requester_group_id:int)-> str: """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()}" + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + return json.dumps({"error": f"Cannot fetch requester group from freshservice: {str(e)}"}) + except Exception as e: + return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) #CREATE REQUESTER GROUP @mcp.tool() async def create_requester_group( name: str, description: Optional[str] = None -) -> Dict[str, Any]: +) -> str: """Create a requester group in Freshservice.""" group_data = {"name": name} if description: @@ -2435,7 +2489,7 @@ async def create_requester_group( try: response = await client.post(url, headers=headers, json=group_data) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None try: @@ -2443,20 +2497,20 @@ async def create_requester_group( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "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]: +async def update_requester_group(id: int,name: Optional[str] = None,description: Optional[str] = None) -> str: """Update an requester group in Freshservice.""" group_data = {} if name: @@ -2465,7 +2519,7 @@ async def update_requester_group(id: int,name: Optional[str] = None,description: group_data["description"] = description if not group_data: - return {"error": "At least one field (name or description) must be provided to update."} + return json.dumps({"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() @@ -2474,7 +2528,7 @@ async def update_requester_group(id: int,name: Optional[str] = None,description: try: response = await client.put(url, headers=headers, json=group_data) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None try: @@ -2482,17 +2536,17 @@ async def update_requester_group(id: int,name: Optional[str] = None,description: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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]: +) -> str: """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() @@ -2500,9 +2554,9 @@ async def list_requester_group_members( async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2511,20 +2565,20 @@ async def list_requester_group_members( 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)}", + return json.dumps({ + "error": f"Failed to fetch list of requester group members: {str(e)}", "status_code": e.response.status_code if e.response else None, "details": error_text - } + }) except Exception as e: - return { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #GET ALL CANNED RESPONSES @mcp.tool() -async def get_all_canned_response() -> Dict[str, Any]: +async def get_all_canned_response() -> str: """List all canned response in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_responses" headers = get_auth_headers() @@ -2532,10 +2586,9 @@ async def get_all_canned_response() -> Dict[str, Any]: 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 + response.raise_for_status() - # Return the response JSON (list of members) - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2544,22 +2597,22 @@ async def get_all_canned_response() -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #GET CANNED RESPONSE BY ID @mcp.tool() async def get_canned_response( id: int -) -> Dict[str, Any]: +) -> str: """Get a canned response in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_responses/{id}" headers = get_auth_headers() @@ -2567,41 +2620,41 @@ async def get_canned_response( 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 + response.raise_for_status() # Only parse JSON if the response is not empty if response.content: - return response.json() + return json.dumps(response.json()) else: - return {"error": "No content returned for the requested canned response."} + return json.dumps({"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)"} + return json.dumps({"error": "Canned response not found (404)"}) else: - return { + return json.dumps({ "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)}"} + return json.dumps({"error": f"Unexpected error: {str(e)}"}) #LIST ALL CANNED RESPONSE FOLDER @mcp.tool() -async def list_all_canned_response_folder() -> Dict[str, Any]: +async def list_all_canned_response_folder() -> str: """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() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2610,22 +2663,22 @@ async def list_all_canned_response_folder() -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #LIST CANNED RESPONSE FOLDER @mcp.tool() async def list_canned_response_folder( id: int -) -> Dict[str, Any]: +) -> str: """List canned response folder in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/canned_response_folders/{id}" headers = get_auth_headers() @@ -2633,9 +2686,9 @@ async def list_canned_response_folder( async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2644,20 +2697,20 @@ async def list_canned_response_folder( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #GET ALL WORKSPACES @mcp.tool() -async def list_all_workspaces() -> Dict[str, Any]: +async def list_all_workspaces() -> str: """List all workspaces in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/workspaces" headers = get_auth_headers() @@ -2665,9 +2718,9 @@ async def list_all_workspaces() -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2676,20 +2729,20 @@ async def list_all_workspaces() -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #GET WORKSPACE @mcp.tool() -async def get_workspace(id: int) -> Dict[str, Any]: +async def get_workspace(id: int) -> str: """Get a workspace by its ID in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/workspaces/{id}" headers = get_auth_headers() @@ -2697,9 +2750,9 @@ async def get_workspace(id: int) -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2708,20 +2761,20 @@ async def get_workspace(id: int) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #GET ALL SOLUTION CATEGORY @mcp.tool() -async def get_all_solution_category() -> Dict[str, Any]: +async def get_all_solution_category() -> str: """Get all solution category in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories" headers = get_auth_headers() @@ -2729,9 +2782,9 @@ async def get_all_solution_category() -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2740,20 +2793,20 @@ async def get_all_solution_category() -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #GET SOLUTION CATEGORY @mcp.tool() -async def get_solution_category(id: int) -> Dict[str, Any]: +async def get_solution_category(id: int) -> str: """Get solution category by its ID in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories/{id}" headers = get_auth_headers() @@ -2761,9 +2814,9 @@ async def get_solution_category(id: int) -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2772,16 +2825,16 @@ async def get_solution_category(id: int) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #CREATE SOLUTION CATEGORY @mcp.tool() @@ -2789,7 +2842,7 @@ async def create_solution_category( name: str, description: str = None, workspace_id: int = None, -) -> Dict[str, Any]: +) -> str: """Create a new solution category in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories" headers = get_auth_headers() @@ -2805,9 +2858,9 @@ async def create_solution_category( async with httpx.AsyncClient() as client: try: response = await client.post(url, headers=headers, json=category_data) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None try: @@ -2815,17 +2868,17 @@ async def create_solution_category( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } - + }) + #UPDATE SOLUTION CATEGORY @mcp.tool() async def update_solution_category( @@ -2834,7 +2887,7 @@ async def update_solution_category( description: str = None, workspace_id: int = None, default_category: bool = None, -) -> Dict[str, Any]: +) -> str: """Update a solution category in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/categories/{category_id}" headers = get_auth_headers() @@ -2853,9 +2906,9 @@ async def update_solution_category( async with httpx.AsyncClient() as client: try: response = await client.put(url, headers=headers, json=category_data) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None try: @@ -2863,20 +2916,20 @@ async def update_solution_category( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "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]: +async def get_list_of_solution_folder(id:int) -> str: """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() @@ -2884,9 +2937,9 @@ async def get_list_of_solution_folder(id:int) -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2895,20 +2948,20 @@ async def get_list_of_solution_folder(id:int) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #GET SOLUTION FOLDER @mcp.tool() -async def get_solution_folder(id: int) -> Dict[str, Any]: +async def get_solution_folder(id: int) -> str: """Get solution folder by its ID in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders/{id}" headers = get_auth_headers() @@ -2916,9 +2969,9 @@ async def get_solution_folder(id: int) -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2927,20 +2980,20 @@ async def get_solution_folder(id: int) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "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]: +async def get_list_of_solution_article(id:int) -> str: """Get list of solution article in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles?folder_id={id}" headers = get_auth_headers() @@ -2948,9 +3001,9 @@ async def get_list_of_solution_article(id:int) -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() + response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2959,20 +3012,20 @@ async def get_list_of_solution_article(id:int) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #GET SOLUTION ARTICLE @mcp.tool() -async def get_solution_article(id:int) -> Dict[str, Any]: +async def get_solution_article(id:int) -> str: """Get solution article by id in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{id}" headers = get_auth_headers() @@ -2980,8 +3033,8 @@ async def get_solution_article(id:int) -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() + response.raise_for_status() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None @@ -2990,16 +3043,16 @@ async def get_solution_article(id:int) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #CREATE SOLUTION ARTICLE @mcp.tool() @@ -3012,7 +3065,7 @@ async def create_solution_article( tags: Optional[List[str]] = None, keywords: Optional[List[str]] = None, review_date: Optional[str] = None # Format: YYYY-MM-DD -) -> Dict[str, Any]: +) -> str: """Create a new solution article in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles" headers = get_auth_headers() @@ -3034,7 +3087,7 @@ async def create_solution_article( try: response = await client.post(url, headers=headers, json=article_data) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None try: @@ -3042,17 +3095,17 @@ async def create_solution_article( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } - + }) + #UPDATE SOLUTION ARTICLE @mcp.tool() async def update_solution_article( @@ -3065,7 +3118,7 @@ async def update_solution_article( tags: Optional[List[str]] = None, keywords: Optional[List[str]] = None, review_date: Optional[str] = None # Format: YYYY-MM-DD -) -> Dict[str, Any]: +) -> str: """Update a solution article in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{article_id}" headers = get_auth_headers() @@ -3087,7 +3140,7 @@ async def update_solution_article( try: response = await client.put(url, headers=headers, json=update_data) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None try: @@ -3095,17 +3148,17 @@ async def update_solution_article( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } - + }) + #CREATE SOLUTION FOLDER @mcp.tool() async def create_solution_folder( @@ -3114,11 +3167,11 @@ async def create_solution_folder( department_ids: List[int], visibility: int = 4, description: Optional[str] = None -) -> Dict[str, Any]: +) -> str: """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."} + + if not department_ids: + return json.dumps({"error": "department_ids must be provided and cannot be empty."}) url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders" headers = get_auth_headers() @@ -3137,7 +3190,7 @@ async def create_solution_folder( try: response = await client.post(url, headers=headers, json=payload) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None try: @@ -3145,16 +3198,16 @@ async def create_solution_folder( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) #UPDATE SOLUTION FOLDER @mcp.tool() @@ -3163,7 +3216,7 @@ async def update_solution_folder( name: Optional[str] = None, description: Optional[str] = None, visibility: Optional[int] = None # Allowed values: 1, 2, 3, 4, 5, 6, 7 -) -> Dict[str, Any]: +) -> str: """Update an existing solution folder's details in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/folders/{id}" headers = get_auth_headers() @@ -3180,8 +3233,8 @@ async def update_solution_folder( try: response = await client.put(url, headers=headers, json=payload) response.raise_for_status() - return response.json() - + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: error_text = None try: @@ -3189,20 +3242,20 @@ async def update_solution_folder( except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } - + }) + #PUBLISH SOLUTION ARTICLE @mcp.tool() -async def publish_solution_article(article_id: int) -> Dict[str, Any]: +async def publish_solution_article(article_id: int) -> str: """Publish a solution article in Freshservice.""" url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/solutions/articles/{article_id}" headers = get_auth_headers() @@ -3211,9 +3264,9 @@ async def publish_solution_article(article_id: int) -> Dict[str, Any]: async with httpx.AsyncClient() as client: try: - response = await client.put(url, headers=headers,json=payload) + response = await client.put(url, headers=headers, json=payload) response.raise_for_status() - return response.json() + return json.dumps(response.json()) except httpx.HTTPStatusError as e: error_text = None try: @@ -3221,16 +3274,16 @@ async def publish_solution_article(article_id: int) -> Dict[str, Any]: except Exception: error_text = e.response.text if e.response else None - return { + return json.dumps({ "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 { + return json.dumps({ "error": f"Unexpected error occurred: {str(e)}" - } + }) # GET AUTH HEADERS def get_auth_headers(): From 43b142a28eb80bac1501026ab8a5d206724e646e Mon Sep 17 00:00:00 2001 From: Vinicius Ribeiro Date: Tue, 17 Mar 2026 13:11:38 -0500 Subject: [PATCH 2/5] fix: add build-system to pyproject.toml for proper packaging The project was missing a [build-system] section, which caused `uv` to skip installing entry points (project.scripts). This meant the `freshservice-mcp` command was not available when running locally via `uv run`, and `uv sync` would warn about skipping entry points. Added hatchling as the build backend with the correct package path for the src layout. --- pyproject.toml | 7 +++ uv.lock | 158 ++++++++++++++++++++++++------------------------- 2 files changed, 86 insertions(+), 79 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6bdd735..06dd429 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,13 @@ email = "maanaesh.s@effy.co.in" name = "Vijay Ragunath" email = "vijay.r@effy.co.in" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/freshservice_mcp"] + [project.scripts] freshservice-mcp = "freshservice_mcp.server:main" diff --git a/uv.lock b/uv.lock index 23f664f..678f354 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,24 +54,24 @@ 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" -source = { virtual = "." } +version = "1.0.0" +source = { editable = "." } dependencies = [ { name = "build" }, { name = "httpx" }, @@ -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 ad6b61cb8847ec28102ca66371471df58514c93b Mon Sep 17 00:00:00 2001 From: Vinicius Ribeiro Date: Wed, 18 Mar 2026 16:25:56 -0500 Subject: [PATCH 3/5] feat: add ticket time entry CRUD tools Add create, list, view, update, and delete tools for ticket time entries. Previously only change time entries were supported. The Freshservice API supports /api/v2/tickets/{id}/time_entries with the same interface. --- src/freshservice_mcp/server.py | 137 ++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/src/freshservice_mcp/server.py b/src/freshservice_mcp/server.py index 1046f30..2332fba 100644 --- a/src/freshservice_mcp/server.py +++ b/src/freshservice_mcp/server.py @@ -409,7 +409,142 @@ async def get_ticket_by_id(ticket_id:int) -> str: return json.dumps({"error": f"Failed to fetch ticket: {str(e)}"}) except Exception as e: return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) - + +# TICKET TIME ENTRIES ENDPOINTS + +#CREATE TICKET TIME ENTRY +@mcp.tool() +async def create_ticket_time_entry( + ticket_id: int, + time_spent: str, + note: str, + agent_id: int, + billable: Optional[bool] = None, + executed_at: Optional[str] = None +) -> str: + """Create a time entry for a ticket. + + Args: + ticket_id: The ID of the ticket + 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 + billable: Whether the time entry is billable + executed_at: When the work was done (ISO format) + """ + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/time_entries" + headers = get_auth_headers() + + data = { + "time_spent": time_spent, + "note": note, + "agent_id": agent_id + } + + if billable is not None: + data["billable"] = billable + 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 json.dumps(response.json()) + except httpx.HTTPStatusError as e: + try: + return json.dumps({"error": str(e), "details": e.response.json()}) + except Exception: + return json.dumps({"error": str(e), "raw_response": e.response.text}) + +#LIST TICKET TIME ENTRIES +@mcp.tool() +async def list_ticket_time_entries(ticket_id: int) -> str: + """List all time entries for a ticket.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_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 json.dumps(response.json()) + except httpx.HTTPStatusError as e: + try: + return json.dumps({"error": str(e), "details": e.response.json()}) + except Exception: + return json.dumps({"error": str(e), "raw_response": e.response.text}) + +#VIEW TICKET TIME ENTRY +@mcp.tool() +async def view_ticket_time_entry(ticket_id: int, time_entry_id: int) -> str: + """View a specific time entry for a ticket.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_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 json.dumps(response.json()) + except httpx.HTTPStatusError as e: + try: + return json.dumps({"error": str(e), "details": e.response.json()}) + except Exception: + return json.dumps({"error": str(e), "raw_response": e.response.text}) + +#UPDATE TICKET TIME ENTRY +@mcp.tool() +async def update_ticket_time_entry( + ticket_id: int, + time_entry_id: int, + time_spent: Optional[str] = None, + note: Optional[str] = None, + billable: Optional[bool] = None +) -> str: + """Update a time entry for a ticket.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_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 + if billable is not None: + data["billable"] = billable + + async with httpx.AsyncClient() as client: + try: + response = await client.put(url, headers=headers, json=data) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + try: + return json.dumps({"error": str(e), "details": e.response.json()}) + except Exception: + return json.dumps({"error": str(e), "raw_response": e.response.text}) + +#DELETE TICKET TIME ENTRY +@mcp.tool() +async def delete_ticket_time_entry(ticket_id: int, time_entry_id: int) -> str: + """Delete a time entry for a ticket.""" + url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_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 json.dumps({"success": True, "message": "Time entry deleted successfully"}) + response.raise_for_status() + return json.dumps(response.json()) + except httpx.HTTPStatusError as e: + try: + return json.dumps({"error": str(e), "details": e.response.json()}) + except Exception: + return json.dumps({"error": str(e), "raw_response": e.response.text}) + #GET ALL CHANGES @mcp.tool() async def get_changes( From af6f91a456f1a08891f7dae9b9e2a767ac3f9812 Mon Sep 17 00:00:00 2001 From: Vinicius Ribeiro Date: Mon, 22 Jun 2026 16:52:38 -0500 Subject: [PATCH 4/5] Fix ticket reply 400 and add attachment support for replies and notes send_ticket_reply always sent from_email, defaulting to a bogus helpdesk@ address that is not a configured support email, so Freshservice rejected every reply with 400. Now from_email is omitted unless explicitly provided, letting Freshservice use the account's default support email. Also: - Add `attachments` (single path or list of local file paths) to send_ticket_reply and create_ticket_note. When present, the request is sent as multipart/form-data with attachments[] parts (images, PDFs, etc.); content type is guessed per file. - Add `private` flag to create_ticket_note (default False = public note visible to the requester; True = internal note). - Surface the API response body as `details` on HTTP errors for both tools to make future failures debuggable. - Add get_auth_headers_multipart(), _coerce_to_list(), and _build_attachment_parts() helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/freshservice_mcp/server.py | 167 +++++++++++++++++++++++++-------- 1 file changed, 126 insertions(+), 41 deletions(-) diff --git a/src/freshservice_mcp/server.py b/src/freshservice_mcp/server.py index 2332fba..e2c26d4 100644 --- a/src/freshservice_mcp/server.py +++ b/src/freshservice_mcp/server.py @@ -1,9 +1,10 @@ import os -import re +import re import httpx import logging import base64 import json +import mimetypes import urllib.parse from typing import Optional, Dict, Union, Any, List from mcp.server.fastmcp import FastMCP @@ -1666,10 +1667,21 @@ async def send_ticket_reply( 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 + bcc_emails: Optional[Union[str, List[str]]] = None, + attachments: Optional[Union[str, List[str]]] = None ) -> str: """ - Send reply to a ticket in Freshservice.""" + Send a public reply (visible to the requester) to a ticket in Freshservice. + + from_email is OPTIONAL. Leave it unset and Freshservice uses the account's + default support email. Only pass from_email if it is a configured support + email address - an arbitrary address causes a 400 error. + + attachments: optional local file path(s) - images, PDFs, etc. - to attach + to the reply. Accepts a single path or a list of paths. When attachments are + present the request is sent as multipart/form-data. (Freshservice limits: + up to 15 files, 40 MB total per request.) + """ # Validation if not ticket_id or not isinstance(ticket_id, int) or ticket_id < 1: @@ -1678,60 +1690,97 @@ async def send_ticket_reply( if not body or not isinstance(body, str) or not body.strip(): return json.dumps({"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) + cc = _coerce_to_list(cc_emails) + bcc = _coerce_to_list(bcc_emails) + attach_paths = _coerce_to_list(attachments) + + async with httpx.AsyncClient() as client: + try: + if attach_paths: + # Attachments require multipart/form-data; JSON cannot carry files. + try: + files = _build_attachment_parts(attach_paths) + except FileNotFoundError as fe: + return json.dumps({"success": False, "error": str(fe)}) + form = [("body", body.strip())] + if from_email: + form.append(("from_email", from_email)) + if user_id is not None: + form.append(("user_id", str(user_id))) + for email in cc: + form.append(("cc_emails[]", email)) + for email in bcc: + form.append(("bcc_emails[]", email)) + response = await client.post( + url, headers=get_auth_headers_multipart(), data=form, files=files + ) + else: + payload = {"body": body.strip()} + if from_email: + payload["from_email"] = from_email + if user_id is not None: + payload["user_id"] = user_id + if cc: + payload["cc_emails"] = cc + if bcc: + payload["bcc_emails"] = bcc + response = await client.post(url, headers=get_auth_headers(), json=payload) response.raise_for_status() return json.dumps(response.json()) except httpx.HTTPStatusError as e: - return json.dumps({"success": False, "error": f"HTTP error occurred: {str(e)}"}) + details = None + try: + details = e.response.json() + except Exception: + details = e.response.text if e.response is not None else None + return json.dumps({"success": False, "error": f"HTTP error occurred: {str(e)}", "details": details}) except Exception as e: return json.dumps({"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)-> str: - """Create a note for a ticket in Freshservice.""" +async def create_ticket_note( + ticket_id: int, + body: str, + private: bool = False, + attachments: Optional[Union[str, List[str]]] = None +) -> str: + """Create a note on a ticket in Freshservice. + + private: False (default) creates a PUBLIC note the requester can see; set + True for an internal/agent-only note. + attachments: optional local file path(s) - images, PDFs, etc. - to attach. + Accepts a single path or a list of paths (sent as multipart/form-data). + """ url = f"https://{FRESHSERVICE_DOMAIN}/api/v2/tickets/{ticket_id}/notes" - headers = get_auth_headers() - data = { - "body": body - } + attach_paths = _coerce_to_list(attachments) + async with httpx.AsyncClient() as client: try: - response = await client.post(url, headers=headers, json=data) + if attach_paths: + # Attachments require multipart/form-data; JSON cannot carry files. + try: + files = _build_attachment_parts(attach_paths) + except FileNotFoundError as fe: + return json.dumps({"error": str(fe)}) + form = [("body", body), ("private", str(bool(private)).lower())] + response = await client.post( + url, headers=get_auth_headers_multipart(), data=form, files=files + ) + else: + data = {"body": body, "private": bool(private)} + response = await client.post(url, headers=get_auth_headers(), json=data) response.raise_for_status() return json.dumps(response.json()) except httpx.HTTPStatusError as e: - return json.dumps({"error": f"Failed to create ticket note: {str(e)}"}) + details = None + try: + details = e.response.json() + except Exception: + details = e.response.text if e.response is not None else None + return json.dumps({"error": f"Failed to create ticket note: {str(e)}", "details": details}) except Exception as e: return json.dumps({"error": f"An unexpected error occurred: {str(e)}"}) @@ -3427,6 +3476,42 @@ def get_auth_headers(): "Content-Type": "application/json" } + +def get_auth_headers_multipart(): + """Auth headers WITHOUT Content-Type so httpx sets the multipart boundary.""" + return { + "Authorization": f"Basic {base64.b64encode(f'{FRESHSERVICE_APIKEY}:X'.encode()).decode()}" + } + + +def _coerce_to_list(value): + """Normalize a str / JSON-encoded-list / list into a plain list of strings.""" + if value is None: + return [] + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else [value] + except json.JSONDecodeError: + return [value] + return list(value) + + +def _build_attachment_parts(attachments): + """Build httpx multipart file parts from a list of local file paths. + + Returns a list of ("attachments[]", (filename, bytes, content_type)) tuples. + Raises FileNotFoundError if any path is missing. + """ + parts = [] + for path in attachments: + if not os.path.isfile(path): + raise FileNotFoundError(f"Attachment not found: {path}") + content_type = mimetypes.guess_type(path)[0] or "application/octet-stream" + with open(path, "rb") as fh: + parts.append(("attachments[]", (os.path.basename(path), fh.read(), content_type))) + return parts + def main(): logging.info("Starting Freshservice MCP server") mcp.run(transport='stdio') From 371053749273034eef47ab6ac065ab0c6b80c537 Mon Sep 17 00:00:00 2001 From: Vinicius Ribeiro Date: Mon, 27 Jul 2026 13:03:10 -0500 Subject: [PATCH 5/5] docs: add specs for Freshservice solutions read tools and Foundry hosting Spec 1 adds 7 read-only knowledge base tools reaching subfolders, article bodies, and attachment text extraction. Spec 2 hosts the server as a remote streamable-http MCP endpoint for Foundry project lrsagenthub-dev-usw3. Both are grounded in measurements against lrs.freshservice.com: folders nest 3 levels deep (55 in one category), article bodies reach 1.28 MB, the search endpoint returns 4 MB for 30 hits, and description_text gives a 150x cheaper plain-text body. Attachment extraction verified with pypdf and openpyxl on real PDFs and spreadsheets. Co-Authored-By: Claude Opus 5 --- ...freshservice-mcp-foundry-hosting-design.md | 249 ++++++++++++ ...reshservice-solutions-read-tools-design.md | 381 ++++++++++++++++++ 2 files changed, 630 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-freshservice-mcp-foundry-hosting-design.md create mode 100644 docs/superpowers/specs/2026-07-27-freshservice-solutions-read-tools-design.md diff --git a/docs/superpowers/specs/2026-07-27-freshservice-mcp-foundry-hosting-design.md b/docs/superpowers/specs/2026-07-27-freshservice-mcp-foundry-hosting-design.md new file mode 100644 index 0000000..87410d2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-freshservice-mcp-foundry-hosting-design.md @@ -0,0 +1,249 @@ +# Hosting freshservice_mcp as a Remote MCP Server for Microsoft Foundry + +**Date:** 2026-07-27 +**Status:** Approved for planning +**Spec 2 of 2.** Executes after `2026-07-27-freshservice-solutions-read-tools-design.md`. + +## Problem + +The Freshservice knowledge base tools must be available to Foundry agents in the project +`lrsagenthub-dev-usw3`. Today `server.py` ends with: + +```python +mcp.run(transport='stdio') +``` + +**Foundry Agent Service only accepts remote MCP server endpoints.** A stdio server cannot be +registered at all. Per Microsoft's documentation, a local MCP server must be self-hosted on Azure +Functions or Azure Container Apps to obtain an endpoint. + +## Verified platform facts + +Confirmed against Microsoft Learn on 2026-07-27. + +### Foundry side + +| Fact | Consequence | +|---|---| +| Agent Service accepts **remote endpoints only** | Hosting is mandatory, not optional | +| **Non-streaming MCP tool calls time out at 50 s** | The binding runtime limit — tighter than an HTTP-triggered Function's 230 s | +| **Max 128 tools registered per agent** | The server has 97; Spec 1 adds 7 → 104. Real headroom, but finite | +| `allowed_tools` filters which tools an agent sees | Lets an agent register only the KB subset instead of all 104 | +| `require_approval` defaults to **`always`** | Left at the default, every call stalls awaiting developer approval | +| **West US 3 supports the MCP tool** | `lrsagenthub-dev-usw3` is in a supported region. Verified in the tool-by-region matrix | +| Tool support requires **both** model and region support | The agent's model deployment must also support MCP | +| Credentials belong in a **project connection** | No secrets in agent definitions or prompts | + +### Azure Functions side + +Functions offers two hosting routes. The distinction matters, because one requires rewriting every +tool and the other does not: + +| Route | What it means here | +|---|---| +| **Functions MCP extension** (`functions-bindings-mcp`) | Rebuild all 104 tools as Functions triggers. Endpoint `/runtime/webhooks/mcp`, secured by the `mcp_extension` system key. **Rejected** — a full rewrite for no benefit | +| **Self-hosted MCP server** (public preview) | Keep the MCP-SDK server as-is and add Functions artifacts. Microsoft: *"You don't need to make any code changes to the server to host it on Azure Functions."* **Chosen** | + +Self-hosted preview requirements, all of which we must satisfy: + +1. **Stateless servers using the `streamable-http` transport.** +2. Python, TypeScript, C#, or Java MCP SDKs. (This repo is the Python SDK — `mcp[cli]>=1.3.0`.) +3. **Must run on a Flex Consumption plan.** +4. App setting `AzureWebJobsFeatureFlags=EnableMcpCustomHandlerPreview`. +5. Python also requires `PYTHONPATH=/home/site/wwwroot/.python_packages/lib/site-packages`. +6. Local runs use `func start` (Core Tools ≥ 4.5.0). F5 debugging is not supported. +7. Creating the Entra app requires permission to do so in the subscription. + +The official sample is `Azure-Samples/mcp-sdk-functions-hosting-python`, scaffolded with +`azd init --template mcp-sdk-functions-hosting-python`. Its `server.py` shows the one meaningful +code change: + +```python +mcp = FastMCP("weather", stateless_http=True) +``` + +## Why Azure Functions over Container Apps + +| | Azure Functions | Container Apps | +|---|---|---| +| Transport | `streamable-http` required | HTTP POST/GET | +| Auth | Built-in App Service auth (Entra, OAuth per MCP authorization spec), or keys | **Custom auth you implement** | +| OS-level deps | Not supported | Anything in the image | +| Containers | Not supported | Required | + +Spec 1's dependencies (`pypdf`, `openpyxl`, `python-docx`) are pure Python with **no OS-level +dependencies**, so the single real Functions restriction does not bind. Container Apps would mean +building and maintaining custom authentication for no gain. + +Note a documentation conflict worth knowing: the Foundry MCP page states Functions hosting is +"key-based only. OAuth needs API Management," while the Functions self-hosted page documents +**built-in App Service OAuth** implementing the MCP authorization spec (401 challenge, Protected +Resource Metadata) via Entra ID. The Functions page is more recent and specific. Auth is therefore +an implementation-time decision — see [Open question](#open-question-authentication-mode). + +## Goals + +- A deployed HTTPS `streamable-http` MCP endpoint reachable by Foundry. +- The 7 KB tools from Spec 1 callable by an agent in `lrsagenthub-dev-usw3`. +- Authenticated; no anonymous access to Freshservice data. +- The Freshservice API key held in Azure config, never in the repo or an agent definition. +- Existing stdio usage (Claude Code, `tests/test-fs-mcp.py`) keeps working unchanged. + +## Non-goals + +- **No new tools or tool-behaviour changes.** Spec 1 owns those. +- **No rewrite to the Functions MCP extension model.** +- **No API Management**, unless the auth decision forces it. +- **No production hardening** (VNet integration, private endpoints, APIM governance, autoscale + tuning). This targets the `-dev-` project; production is a later concern. +- **No Azure API Center registration**, though it is the documented path for an org-wide private + tool catalog and is worth revisiting once this proves out. + +## Architecture + +``` +freshservice_mcp/ + src/freshservice_mcp/ + server.py (edit) stateless_http=True; transport selected by env var + common.py (Spec 1) + solutions.py (Spec 1) + function_app.py (new) Functions entry point + host.json (new) Functions host config + requirements.txt (new) Functions deployment deps + infra/ (new) Bicep from the azd template + azure.yaml (new) azd service definition + .funcignore (new) excludes .venv, tests, docs from the package +``` + +### Dual transport + +`main()` selects transport from an environment variable so one codebase serves both consumers: + +```python +def main(): + transport = os.getenv("MCP_TRANSPORT", "stdio") + mcp.run(transport=transport) +``` + +Default `stdio` keeps Claude Code and the existing test file working with **no config change**. The +Function App sets `MCP_TRANSPORT=streamable-http`. + +`FastMCP` is constructed with `stateless_http=True`, which the preview requires. This is safe for +stdio use and is the only change to how the server is instantiated. + +### Configuration + +| Setting | Value | Notes | +|---|---|---| +| `FRESHSERVICE_DOMAIN` | `lrs.freshservice.com` | App Setting | +| `FRESHSERVICE_APIKEY` | Key Vault reference | **Never** a literal App Setting | +| `MCP_TRANSPORT` | `streamable-http` | | +| `AzureWebJobsFeatureFlags` | `EnableMcpCustomHandlerPreview` | Required by the preview | +| `PYTHONPATH` | `/home/site/wwwroot/.python_packages/lib/site-packages` | Required for Python | + +The Function App gets a system-assigned managed identity with a Key Vault `get` secret role +assignment. `load_dotenv()` in `server.py` is harmless in Azure (no `.env` present) and stays for +local development. + +### Foundry wiring + +In `lrsagenthub-dev-usw3`: + +1. Create a project connection holding the endpoint credential. +2. Attach the MCP tool to the agent: + +```python +MCPTool( + server_label="freshservice_kb", + server_url="https://.azurewebsites.net/mcp", + project_connection_id="", + allowed_tools=[ + "list_solution_subfolders", + "get_solution_folder_tree", + "list_solution_articles_metadata", + "get_solution_article_content", + "list_solution_article_attachments", + "read_solution_attachment", + "search_solution_articles", + "get_all_solution_category", + "get_solution_category", + ], + require_approval="never", +) +``` + +Two settings carry most of the value: + +- **`allowed_tools`** exposes 9 KB tools instead of all 104. Beyond staying clear of the 128-tool + cap, this is the single biggest lever on tool-selection quality — Microsoft's own guidance is + "register only required tools; prefer fewer, reusable tools." An agent shown 104 Freshservice + tools will pick badly. +- **`require_approval="never"`** is safe *because* Spec 1's tools are strictly read-only. Had they + included writes, the default `always` would be correct. + +The exact `server_url` path suffix (`/mcp` vs the app root) is set by the azd template's routing; +confirm from the `azd up` output rather than assuming. + +## Open question: authentication mode + +Two viable modes, to be settled during implementation once the endpoint exists: + +**A. Built-in App Service auth (Entra ID)** — the azd template's default, implementing the MCP +authorization spec. Strongest option, no shared secret, and it matches Foundry's recommendation to +prefer Entra when the server supports it. Requires permission to create an Entra app, and Foundry +must be configured with the matching audience. + +**B. Function key** — simpler; the key lives in a Foundry project connection. Adequate for a dev +project, but a long-lived shared secret. + +Recommendation: attempt A first, since the template scaffolds it. Fall back to B if Entra app +creation or the Foundry audience configuration blocks progress. Not a blocker either way — the work +below is identical. + +## Implementation phases + +**Phase 1 — Local streamable-http.** Add `stateless_http=True` and the `MCP_TRANSPORT` switch. Run +`mcp.run(transport="streamable-http")` locally and verify `tools/list` returns 104 tools and a KB +tool executes end to end. Confirm stdio still works. + +**Phase 2 — Functions scaffold.** Scaffold the azd template into a scratch directory and port its +artifacts (`function_app.py`, `host.json`, `azure.yaml`, `infra/`, `.funcignore`). Generate +`requirements.txt` from `pyproject.toml`. Run `uv run func start` locally — the repo already uses +`uv`, so this fits existing tooling. Verify with MCP Inspector. + +**Phase 3 — Deploy.** `azd up` into the LRS dev subscription on a Flex Consumption plan. Set the app +settings above, wire the Key Vault reference and managed identity, and confirm the endpoint responds +to an authenticated `tools/list`. + +**Phase 4 — Foundry integration.** Create the project connection, attach the MCP tool with +`allowed_tools` and `require_approval="never"`, and run agent smoke tests: + +- "What folders exist under CX Rosemont FSO?" → exercises the folder tree +- "What's in the Shields Township price sheet?" → exercises article content +- "What does the hauling garage spreadsheet say for Aledo?" → exercises attachment extraction + end-to-end (attachment 31009362843; expected answer: garage `MONMOUTH`, rep `STEVE RAMOS`) + +The third test is the real acceptance criterion: it proves an agent can read *inside* an attachment, +which is the goal that started this work. + +## Risks + +| Risk | Severity | Mitigation | +|---|---|---| +| **`requires-python = ">=3.13"` may exceed the Functions Python runtime.** `.python-version` pins 3.13 | **High** | Verify supported Flex Consumption Python versions in Phase 2 *before* deploying. If 3.13 is unsupported, relax to `>=3.11` — nothing in the codebase requires 3.13 | +| Self-hosted MCP hosting is **public preview** | Medium | Accepted for a dev project. The Functions MCP extension is the fallback, at the cost of a rewrite | +| **Flex Consumption plan required** — differs from LRS's existing App Service Plans | Medium | New dedicated plan; do not attach to a shared plan. See the `lrs-enterprise-apps` guidance on not disturbing shared plans | +| 50 s Foundry tool-call timeout | Medium | Already designed for in Spec 1 (bounded traversal, size caps, one attachment per call) | +| Cold start on Flex Consumption with `pypdf`/`openpyxl` | Low–Medium | All pure Python and small. Measure in Phase 3; consider an always-ready instance if it bites | +| 104 tools makes a large `tools/list` payload | Low | Under the 128 cap; `allowed_tools` narrows what the agent sees | +| Entra app creation may be blocked by tenant policy | Low | Auth mode B is the documented fallback | +| Transport change could regress the 97 existing tools | Low | Transport is orthogonal to tool logic; `stdio` remains the default so existing consumers are untouched | +| Preview feature flag `EnableMcpCustomHandlerPreview` may change | Low | Pinned in Bicep; revisit at GA | + +## Success criteria + +1. An authenticated `tools/list` against the deployed endpoint returns all 104 tools. +2. Both stdio and streamable-http work from one codebase with no code edits between them. +3. A Foundry agent in `lrsagenthub-dev-usw3` reads a KB article body. +4. That agent reads the contents of an XLSX attachment and answers a question from a specific row. +5. No credential appears in the repo, an App Setting literal, or an agent definition. diff --git a/docs/superpowers/specs/2026-07-27-freshservice-solutions-read-tools-design.md b/docs/superpowers/specs/2026-07-27-freshservice-solutions-read-tools-design.md new file mode 100644 index 0000000..938c92f --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-freshservice-solutions-read-tools-design.md @@ -0,0 +1,381 @@ +# Freshservice Solutions / Knowledge Base — Deep Read Tools + +**Date:** 2026-07-27 +**Status:** Approved for planning +**Spec 1 of 2.** Spec 2 (`2026-07-27-freshservice-mcp-foundry-hosting-design.md`) covers hosting the +server as a remote MCP endpoint for Microsoft Foundry. This spec is written to that target's +constraints but does not depend on it — every tool here is testable over stdio today. + +## Problem + +The MCP server has six solution tools (`get_all_solution_category`, `get_solution_category`, +`get_list_of_solution_folder`, `get_solution_folder`, `get_list_of_solution_article`, +`get_solution_article`). They cannot reach the knowledge base's deepest levels, and the levels they +do reach return payloads far too large to put in an agent's context. + +Two concrete failures: + +1. **Subfolders are unreachable.** `get_list_of_solution_folder` calls + `solutions/folders?category_id=X`, which returns only top-level folders. Folders carry + `has_subfolders` and `parent_id`, and the category `CX Rosemont FSO` (31000021923) contains + **55 folders nested 3 levels deep**. Everything below level 0 is invisible. +2. **Every tool passes raw JSON through.** All six do `json.dumps(response.json())`. The article + list endpoint embeds each article's full `description` HTML, so one folder page returns up to + **1.9 MB**. + +The goal: read the knowledge base down to article bodies and attachment contents, in payloads an +agent can actually consume. + +## Verified API behaviour + +Measured against `lrs.freshservice.com` on 2026-07-27. These numbers drive every design decision +below; they are not estimates. + +| Observation | Evidence | +|---|---| +| Folders form a recursive tree | `solutions/folders?parent_id=` returns children. Category 31000021923 → 55 folders, `max_depth=3` | +| Article bodies are enormous | One article's `description` = **461,674 bytes**; largest found = **1,280,210 bytes** | +| `description_text` is supplied by the API | Same article: 461,674 B HTML → **3,024 B** plain text. No HTML converter needed | +| List responses embed full bodies | `description` was **99%** of a 466 KB single-article response. Folder pages reached 1.9 MB | +| `per_page` / `page` work; no `Link` header | `per_page=15` over folder 31000033288 → 15, 15, 1. End of list = short page | +| Search works, unusably raw | `solutions/articles/search?search_term=price%20sheet` → 30 results = **4,009,746 bytes** | +| `attachments` shape differs by endpoint | **Detail:** list of objects (`id`, `name`, `content_type`, `size`, `attachment_url`, `canonical_url`, `has_access`). **List:** list of filename strings + parallel `attachment_urls` | +| Presigned URLs last 24 h | `Expires` − now = **86,400 s**. Tampered signature → 403 | +| Download by ID works in one call | `GET /api/v2/attachments/{id}` + `follow_redirects=True` → 200, 304,943 B, **0.70 s**. Forwarding the Basic auth header does not break S3. Bad ID → clean 404 | +| `content-length` precedes the body | Enables rejecting oversized files before buffering | +| `size: 0` attachments exist | 2 of 14 sampled returned HTTP 200 with **0 bytes**. A real data condition, not an error | +| Extraction works on real files | `openpyxl` → 123 rows from `HAULING 2-3-251.xlsx`. `pypdf` → 800–964 chars/page across 4 enrollment PDFs, text layer present | +| Rate limit | `x-ratelimit-total: 500` per minute | + +## Goals + +- Reach every folder level, including subfolders. +- Read article bodies with bounded, pageable output. +- List attachment metadata and extract attachment **text contents** (PDF, XLSX, DOCX, CSV, TXT). +- Search articles with lean output. +- Every tool returns in well under 50 s and in a size an agent can consume. + +## Non-goals + +- **No writes.** Read-only. Existing create/update/publish tools are untouched. +- **No transport or hosting changes.** Spec 2. +- **No OCR.** Scanned PDFs with no text layer are reported as such, not processed. +- **No caching or indexing.** Every call is stateless and self-contained (a hosting requirement). +- **No changes to the six existing solution tools.** Back-compat is absolute; `tests/test-fs-mcp.py` + imports them by name. + +## Constraints inherited from Spec 2 + +Recorded here because they shape the tool signatures, and honouring them now is free: + +1. **50 s timeout** on non-streaming MCP tool calls in Foundry Agent Service. The binding limit — + tighter than an HTTP-triggered Function's 230 s. No unbounded traversal, no multi-attachment + extraction per call. +2. **128 tools per agent.** The server already has 97. Adding 7 → 104. +3. **Stateless only.** No local disk, no cross-call state. A returned file path would be a dead + reference to the client, which is why attachment text is extracted server-side rather than + downloaded and pointed at. +4. **Pure-Python dependencies only.** Azure Functions does not support OS-level dependencies. + +## Architecture + +`server.py` is 3,520 lines / 131 KB. Appending 7 tools makes a bad file worse, so: + +``` +src/freshservice_mcp/ + common.py (new) shared config, auth headers, error envelope, small helpers + solutions.py (new) the 7 tools + pure transform helpers; exposes register(mcp) + server.py (edit) imports from common; calls solutions.register(mcp) +``` + +**`common.py`** holds what both modules need, moved verbatim out of `server.py`: +`FRESHSERVICE_DOMAIN`, `FRESHSERVICE_APIKEY`, `get_auth_headers()`, +`get_auth_headers_multipart()`, `_coerce_to_list()`, plus a new `error_envelope()` factoring the +`except httpx.HTTPStatusError` block, which is currently copy-pasted **94 times** in `server.py`. + +`server.py` replaces its local definitions with `from .common import ...`. Because the names stay +resident in `server.py`'s namespace, **all 97 existing call sites and every test import keep +working unchanged.** This is the only edit to existing code. + +**`solutions.py`** defines `register(mcp)`, called from `server.py` after the `mcp` instance is +created. This avoids a circular import without restructuring how `mcp` is created. + +Transform logic (projection, truncation, tree bounding, extraction dispatch) lives in module-level +pure functions, separate from the `@mcp.tool()` coroutines that do I/O. That split is what makes +the logic testable without network access — see [Testing](#testing). + +## Shared contracts + +**Truncation.** Every tool returning text emits the same four fields, so an agent can page +deterministically without guessing: + +```json +{ "total_chars": 461674, "returned_chars": 20000, "truncated": true, "next_offset": 20000 } +``` + +When `truncated` is `false`, `next_offset` is `null`. + +**Pagination.** Tools take `page` (1-based) and `per_page`. Since the API sends no `Link` header, +responses include `"has_more": `, derived from whether the page came back full. + +**Error envelope.** Matches the existing style so behaviour is consistent across the server: + +```json +{ "error": "", "status_code": , "details": } +``` + +**Defaults**, chosen from the measurements above: + +| Parameter | Default | Rationale | +|---|---|---| +| `max_chars` | 20,000 | ~5k tokens. The 1.28 MB article pages in ~64 slices | +| `max_bytes` | 20,971,520 (20 MB) | Rejected via `content-length` before buffering. Largest attachment seen: 1.16 MB | +| `max_depth` | 3 | Matches the deepest observed nesting | +| `max_nodes` | 300 | The 55-folder category walks in ~10 HTTP calls; this bounds pathological categories | +| `per_page` | 30 | The API default | +| `max_rows` (XLSX) | 500 | `HAULING 2-3-251.xlsx` has 123 | + +**Concurrency.** Tree traversal issues sibling requests concurrently through an +`asyncio.Semaphore(5)`. Sequential walking risks the 50 s budget; unbounded concurrency risks the +500-req/min rate limit. + +## Tools + +### 1. `list_solution_subfolders` + +```python +async def list_solution_subfolders( + parent_folder_id: int, page: int = 1, per_page: int = 30 +) -> str +``` + +`GET solutions/folders?parent_id={parent_folder_id}`. The capability that does not exist today. + +Per folder, projects: `id`, `name`, `description`, `parent_id`, `category_id`, `has_subfolders`, +`visibility`, `position`, `updated_at`. Drops the portal/department/group plumbing +(`manage_by_group_ids`, `portal_ids`, `portal_access_mode`, `approval_settings`, +`folder_department_ids`, …), which an agent reading the KB has no use for. + +Returns `{ "folders": [...], "count": n, "has_more": bool }`. + +### 2. `get_solution_folder_tree` + +```python +async def get_solution_folder_tree( + category_id: int, max_depth: int = 3, max_nodes: int = 300 +) -> str +``` + +Bounded breadth-first walk. Starts at `solutions/folders?category_id={category_id}`, then recurses +into any folder with `has_subfolders: true` until `max_depth` or `max_nodes` is hit. + +Returns a nested tree of `{id, name, depth, has_subfolders, article_count: null, children: [...]}` +plus a `limits` block: + +```json +{ "limits": { "max_depth": 3, "max_nodes": 300, "nodes_returned": 55, + "truncated": false, "depth_reached": 3, "elapsed_ms": 3120 } } +``` + +`truncated: true` means a bound was hit and the agent should drill in with +`list_solution_subfolders` rather than assume it has the whole tree. `article_count` is `null` +because obtaining it would require one article call per folder — far past the 50 s budget. + +### 3. `list_solution_articles_metadata` + +```python +async def list_solution_articles_metadata( + folder_id: int, page: int = 1, per_page: int = 30, + include_preview: bool = False, preview_chars: int = 300 +) -> str +``` + +`GET solutions/articles?folder_id={folder_id}&page&per_page`, then **discards `description` and +`description_text`** — the transformation that turns a 1.9 MB response into roughly 2 KB. + +Per article, projects: `id`, `title`, `status`, `article_type`, `folder_id`, `category_id`, +`views`, `thumbs_up`, `thumbs_down`, `created_at`, `updated_at`, `review_date`, `keywords`, +plus derived `attachment_count` (length of the `attachments` array) and `body_chars` +(`len(description_text)`, so an agent can judge cost before fetching). + +With `include_preview=True`, adds `preview`: the first `preview_chars` of `description_text`. +Off by default — 30 articles × 300 chars is another 9 KB. + +Returns `{ "articles": [...], "count": n, "has_more": bool }`. + +### 4. `get_solution_article_content` + +```python +async def get_solution_article_content( + article_id: int, format: str = "text", offset: int = 0, max_chars: int = 20000 +) -> str +``` + +`GET solutions/articles/{article_id}`. The primary "read what's inside" tool. + +- `format="text"` (default) → the API's own `description_text`. +- `format="html"` → raw `description`. Same truncation applies. For agents this is 150× more + expensive for the same information, so the docstring must say so explicitly. +- Any other value → error naming the two valid options. + +Returns `title`, `status`, `article_type`, `folder_id`, `category_id`, `tags`, `keywords`, +`updated_at`, `attachment_count`, `content`, and the four truncation fields. + +If `description_text` is empty but `description` is not (an article whose body is entirely images +or tables), returns `content: ""` with `"note": "No plain-text body; retry with format='html'"` +rather than a bare empty string. + +`offset` beyond `total_chars` returns empty content with `truncated: false`, not an error. + +### 5. `list_solution_article_attachments` + +```python +async def list_solution_article_attachments(article_id: int) -> str +``` + +`GET solutions/articles/{article_id}` — the **detail** endpoint specifically, because it returns +attachment objects where the list endpoint returns bare filename strings. + +Per attachment, projects `id`, `name`, `content_type`, `size`, `created_at`, `canonical_url`, +`attachment_url`, `has_access`, plus two derived flags: + +- `is_empty` — `size == 0`. Verified real: 2 of 14 sampled. Surfaced as data, not an error, so the + agent knows not to bother calling `read_solution_attachment`. +- `extractable` — whether `content_type` maps to a supported extractor. + +Both URLs are included deliberately: `attachment_url` is presigned and valid **24 h**, so a client +that needs the actual binary (to open a spreadsheet, or fill in an enrollment form) can fetch it +directly. Extraction serves reading; the URL serves fetching. + +Also returns `cloud_files` verbatim (observed `null`/`[]`; no sample available to design against). + +### 6. `read_solution_attachment` + +```python +async def read_solution_attachment( + attachment_id: int, offset: int = 0, max_chars: int = 20000, + max_bytes: int = 20971520, page_range: Optional[str] = None, + sheet: Optional[str] = None, max_rows: int = 500 +) -> str +``` + +Takes an `attachment_id` from tool 5 and needs nothing else — verified that +`GET /api/v2/attachments/{id}` with `follow_redirects=True` returns the file in one 0.70 s call. + +**Parameter ordering is significant.** `page_range`, `sheet`, and `max_rows` select *what gets +extracted*; `offset` and `max_chars` then page through the resulting text. So `page_range="1-5"` +with `offset=0, max_chars=20000` returns the first 20,000 characters **of pages 1–5 only**, and +`total_chars` reports the length of that selection, not of the whole document. The docstring must +state this, or an agent will misread `total_chars` as the document size. + +Sequence: + +1. Open a streaming request. Read `content-length`; if it exceeds `max_bytes`, abort **before + buffering** and return an error carrying the declared size so the agent can decide. +2. Stream into memory with a running cap (defence for a missing `content-length`). +3. If 0 bytes → `{"is_empty": true, "text": "", ...}`, not an error. +4. Dispatch on `content-type`, falling back to the filename extension. +5. Truncate via the shared contract. + +| Type | Library | Notes | +|---|---|---| +| `application/pdf` | `pypdf` | Optional `page_range` (`"1-5"`, `"3"`). Per-page text joined with `\n\n`. Returns `page_count`. If total extracted text is empty across pages, returns `text_layer: false` and a note that the PDF is likely scanned — no OCR | +| `…spreadsheetml.sheet` (XLSX) | `openpyxl` | `read_only=True, data_only=True`. Optional `sheet` name; default all, capped at `max_rows` per sheet. Rows joined with tab, sheets prefixed `## `. Returns `sheet_names`. **Caveat:** `data_only=True` returns cached values, so formula cells never calculated by Excel read as `None` | +| `…wordprocessingml.document` (DOCX) | `python-docx` | Paragraphs, then tables as tab-joined rows | +| `text/csv`, `text/plain`, `application/json` | stdlib | Decode UTF-8, fall back to latin-1 (never raises) | +| Anything else (images, legacy `.xls`/`.doc`, ZIP) | — | `extractable: false` + metadata + `attachment_url`. Not an error: the correct outcome is telling the agent to hand the URL to a human | + +Extraction runs inside `asyncio.to_thread` so a large PDF parse does not block the event loop. + +A malformed file that defeats its parser returns an error envelope naming the library and +exception rather than propagating a traceback. + +### 7. `search_solution_articles` + +```python +async def search_solution_articles( + search_term: str, page: int = 1, per_page: int = 30, preview_chars: int = 300 +) -> str +``` + +`GET solutions/articles/search?search_term={term}`, with the same projection as tool 3 — the +transformation that turns the measured 4,009,746-byte response into a few KB. + +`search_term` is URL-encoded via `urllib.parse.quote` (already imported in `server.py`). Includes +a `preview` per hit by default — unlike tool 3, because a search result is worthless without a +snippet showing why it matched. + +Returns `{ "articles": [...], "count": n, "has_more": bool, "search_term": "..." }`. + +## Dependencies + +Added to `pyproject.toml`: + +```toml +"pypdf>=5.1.0", +"openpyxl>=3.1.5", +"python-docx>=1.1.2", +``` + +All three are pure Python with no OS-level dependencies — the property that keeps Azure Functions +viable in Spec 2. **`pandas` is explicitly excluded**: tens of MB, slow cold start, and `openpyxl` +already does the job. + +## Testing + +The existing `tests/test-fs-mcp.py` is print-based scripts against the live API with no assertions +and no test runner; the project has no test dependency. This spec does not rewrite it, but new work +gets real tests. + +Add `pytest`, `pytest-asyncio`, and `respx` (an httpx mock transport) as dev dependencies, and +`tests/test_solutions.py`: + +**Pure transform tests** — no network, the bulk of the value: + +- Article projection drops `description`/`description_text` and derives `attachment_count` / + `body_chars`. Asserted against a **captured real fixture**, so the 99%-of-payload reduction is + proven rather than assumed. +- Truncation: `offset`/`max_chars` slicing; `next_offset` arithmetic; `offset` past the end; + `truncated` false on exact-fit; last page sets `next_offset: null`. +- Tree bounding: `max_depth` stops recursion; `max_nodes` sets `truncated: true`; a folder claiming + `has_subfolders: true` that returns none does not loop or crash. +- Attachment flags: `size == 0` → `is_empty`; content-type → `extractable` mapping, including an + unknown type and extension fallback. +- Extraction dispatch by content type, with unknown types returning `extractable: false`. + +**Mocked HTTP tests** via `respx`: 404 on a bad ID yields the error envelope; a 401 does too; +`content-length` over `max_bytes` aborts without reading the body; the 302 → presigned redirect is +followed. + +**Extraction tests** against small committed fixtures — a 2-page PDF, a 2-sheet XLSX, a DOCX, a +latin-1 CSV — plus a zero-byte file and a truncated/corrupt PDF asserting a clean error envelope. + +Live-API checks stay manual and follow the existing file's convention, exercising each tool against +category 31000021923 (55 folders, depth 3), article 31000459933 (461 KB body), article 31000098068 +(11 attachments), and attachment 31009362843 (the 304 KB XLSX). + +## Risks + +| Risk | Mitigation | +|---|---| +| A category far larger than the one sampled blows the 50 s budget in `get_solution_folder_tree` | `max_depth` / `max_nodes` / `Semaphore(5)`, and `elapsed_ms` + `truncated` reported so the agent can tell it got a partial tree | +| `description_text` quality varies; may be empty for image-only articles | Detected and reported with a note pointing at `format="html"` | +| Scanned PDFs yield no text | `text_layer: false` and an explicit note. OCR is out of scope | +| XLSX formula cells read as `None` under `data_only=True` | Documented in the tool docstring; the alternative (`data_only=False`) returns formula strings, which are less useful to an agent | +| Presigned URLs expire after 24 h, so a URL handed to a user can go stale | `canonical_url` is also returned as the durable handle; `read_solution_attachment` always fetches fresh by ID | +| 500 req/min rate limit | Bounded concurrency; a 429 surfaces through the standard error envelope | +| Extracting a 20 MB PDF could approach the 50 s ceiling | Default `max_bytes` is 20 MB against a largest-observed 1.16 MB; extraction runs in a worker thread; `page_range` lets an agent take a slice | +| Splitting helpers into `common.py` touches a file with 97 working tools | Names are re-imported into `server.py`'s namespace, so call sites and test imports are unchanged. Verified by importing every name `tests/test-fs-mcp.py` imports | + +## Foundry readiness + +Deliberately satisfied here so Spec 2 is purely an infrastructure change: + +- Every tool returns in well under 50 s; no tool loops unboundedly. +- No local-disk or cross-call state; attachment text crosses the wire. +- Pure-Python dependencies only. +- 7 tools → 104 total, under the 128-per-agent cap. +- Tool names all carry `solution`, so Foundry's `allowed_tools` can select the KB subset by prefix + and agents are not shown all 104. +- All 7 are read-only, so `require_approval: "never"` is safe — Foundry defaults to `always`, which + would otherwise stall every call awaiting developer approval.