Source code for cursus.mcp.envelope
"""
The result envelope every cursus MCP tool returns.
Tools never raise across the tool boundary and never print. They return a
:class:`ToolResult` — a small, JSON-serializable success/error envelope — so that any
agent framework can consume the outcome uniformly. The server adapter and the in-process
:func:`cursus.mcp.registry.call_tool` both rely on this contract.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
[docs]
class ToolError(Exception):
"""
Raised inside a tool to signal a *handled*, user-facing failure.
The registry's invoker catches this and converts it to ``ToolResult.failure(...)``,
preserving ``code`` and ``details``. Use this for expected failures (bad input,
not-found, validation) — let genuinely unexpected exceptions propagate so the
invoker can wrap them as an ``internal_error``.
"""
def __init__(
self,
message: str,
code: str = "tool_error",
details: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.message = message
self.code = code
self.details = details or {}
[docs]
@dataclass
class ToolResult:
"""
Uniform tool outcome.
Attributes:
ok: Whether the tool succeeded.
data: JSON-serializable payload on success (None on error).
error: Human-readable error message on failure (None on success).
code: Machine-readable error code on failure (e.g. ``"not_found"``,
``"invalid_input"``, ``"internal_error"``).
warnings: Non-fatal messages an agent may surface or reason about.
next_steps: Optional in-band guidance for an agent on a *successful* result —
a list of ``{"tool", "when", "why"}`` (``"args_hint"`` optional) entries
naming the tool(s) the agent would typically call next. Lets the golden
path live on the result instead of in the agent's prompt.
remedy: Optional in-band recovery hint on a *failure* —
``{"suggested_tools": [...], "fix_action": str}`` naming the tool(s) or
action that typically resolves this error, so the agent does not have to
reverse-engineer a remedy from the bare ``code``.
meta: Optional side-band info (tool name, timings, counts) — never required
for correctness.
"""
ok: bool
data: Optional[Any] = None
error: Optional[str] = None
code: Optional[str] = None
warnings: List[str] = field(default_factory=list)
next_steps: List[Dict[str, Any]] = field(default_factory=list)
remedy: Optional[Dict[str, Any]] = None
meta: Dict[str, Any] = field(default_factory=dict)
# --- constructors ---
[docs]
@classmethod
def success(
cls,
data: Any = None,
warnings: Optional[List[str]] = None,
next_steps: Optional[List[Dict[str, Any]]] = None,
**meta: Any,
) -> "ToolResult":
return cls(
ok=True,
data=data,
warnings=list(warnings or []),
next_steps=list(next_steps or []),
meta=dict(meta),
)
[docs]
@classmethod
def failure(
cls,
message: str,
code: str = "tool_error",
details: Optional[Dict[str, Any]] = None,
warnings: Optional[List[str]] = None,
remedy: Optional[Dict[str, Any]] = None,
) -> "ToolResult":
meta: Dict[str, Any] = {}
if details:
meta["details"] = details
return cls(
ok=False,
error=message,
code=code,
warnings=list(warnings or []),
remedy=remedy,
meta=meta,
)
# --- serialization ---
[docs]
def to_dict(self) -> Dict[str, Any]:
"""Render to a plain JSON-serializable dict (drops empty optional fields)."""
out: Dict[str, Any] = {"ok": self.ok}
if self.ok:
out["data"] = self.data
if self.next_steps:
out["next_steps"] = self.next_steps
else:
out["error"] = self.error
out["code"] = self.code
if self.remedy:
out["remedy"] = self.remedy
if self.warnings:
out["warnings"] = self.warnings
if self.meta:
out["meta"] = self.meta
return out