@mcp.tool()
async def cancel_document(
doctype: str,
name: str
) -> str:
"""
Cancel a document in Frappe (change docstatus from 1 to 2).
This tool handles document cancellation using Frappe's cancellation workflow,
including proper validation and error handling to provide clear feedback
for corrective action.
Args:
doctype: DocType name
name: Document name (case-sensitive)
Returns:
Success message if cancelled, or detailed error information if cancellation
fails due to validation errors, linked documents, or permission issues.
"""
try:
client = get_client()
# First, get the current document to check its status and get full data
try:
doc_response = await client.get(f"api/resource/{doctype}/{name}")
doc_data = doc_response.get("data", {})
current_docstatus = doc_data.get("docstatus", None)
if current_docstatus is None:
return f"Error: Could not retrieve document {doctype} '{name}'. Document may not exist."
if current_docstatus == 0:
return f"Document {doctype} '{name}' is in Draft status. Only submitted documents (docstatus=1) can be cancelled."
if current_docstatus == 2:
return f"Document {doctype} '{name}' is already cancelled."
if current_docstatus != 1:
return f"Document {doctype} '{name}' has unexpected status (docstatus={current_docstatus}). Only submitted documents (docstatus=1) can be cancelled."
except Exception as get_error:
return f"Error retrieving document for cancellation: {get_error}"
# Prepare document for cancellation by setting docstatus to 2
cancel_doc = doc_data.copy()
cancel_doc['docstatus'] = 2
# Use Frappe's savedocs method which handles the cancellation workflow
response = await client.post(
"api/method/frappe.desk.form.save.savedocs",
json_data={
"doc": json.dumps(cancel_doc),
"action": "Cancel"
}
)
# Check if cancellation was successful
if "docs" in response:
cancelled_doc = response["docs"][0] if response["docs"] else {}
final_docstatus = cancelled_doc.get("docstatus", 0)
if final_docstatus == 2:
return f"✅ Document {doctype} '{name}' successfully cancelled."
else:
return f"⚠️ Cancellation completed but document status is {final_docstatus} (expected 2)."
# If we get here, check for success without docs
if response.get("message") == "ok" or "exc" not in response:
return f"✅ Document {doctype} '{name}' successfully cancelled."
# If no explicit success indicator, assume it worked
return f"✅ Document {doctype} '{name}' cancellation completed."
except FrappeApiError as api_error:
# Handle specific Frappe API errors with detailed information
if api_error.response_data:
error_data = api_error.response_data
# Check for validation errors in the response
if "exception" in error_data:
exception_msg = error_data["exception"]
# Extract user-friendly error messages
if "ValidationError" in str(exception_msg):
# Common cancellation validation errors
if "Cannot cancel" in str(exception_msg) and "linked" in str(exception_msg).lower():
return (
f"❌ Cancellation failed: Document {doctype} '{name}' cannot be cancelled because it has linked documents. "
f"You may need to cancel or unlink related documents first before cancelling this document."
)
elif "Cannot cancel" in str(exception_msg):
return f"❌ Cancellation failed: {exception_msg}. Check document constraints and linked records."
else:
# Generic validation error
return f"❌ Validation error: {exception_msg}. Please fix the validation issues and try again."
elif "PermissionError" in str(exception_msg):
return f"❌ Permission denied: You don't have sufficient permissions to cancel {doctype} documents."
else:
# Other exceptions
return f"❌ Cancellation failed: {exception_msg}"
# Check for server messages with more details
if "_server_messages" in error_data:
try:
messages = json.loads(error_data["_server_messages"])
if messages:
msg_data = json.loads(messages[0])
user_message = msg_data.get("message", "Unknown error")
return f"❌ Cancellation failed: {user_message}"
except (json.JSONDecodeError, KeyError, IndexError):
pass
return f"❌ Cancellation failed: {api_error}"
except Exception as error:
return _format_error_response(error, "cancel_document")