Skip to main content

headless_lms_mcp/tools/
mod.rs

1//! Tool trait, registry and the shapes handlers return. Handlers get a [`RequestContext`] and
2//! return structured results; the protocol layer turns them into MCP results.
3
4pub mod args;
5pub mod catalog;
6mod error;
7mod handler;
8pub mod schema;
9
10use serde::Serialize;
11use serde_json::{Map, Value, json};
12
13use crate::hints::Hint;
14use crate::protocol::context::ClientContext;
15use crate::protocol::progress::ProgressReporter;
16
17pub use error::{ToolError, ToolErrorCode};
18pub use handler::{Tool, ToolHandler, ToolRegistry};
19pub use headless_lms_models::library::oauth::mcp::{McpPrincipal, McpScope};
20
21/// Echoed state of a confirmation round trip, already verified.
22#[derive(Debug, Clone)]
23pub struct RetryState {
24    /// The client's answers keyed like the input requests (`"confirm"`).
25    pub input_responses: Map<String, Value>,
26}
27
28impl RetryState {
29    /// The action the user took on the confirmation, if any (`accept`, `decline`, `cancel`).
30    pub fn confirmation_action(&self) -> Option<&str> {
31        self.input_responses
32            .get(CONFIRMATION_KEY)?
33            .get("action")?
34            .as_str()
35    }
36}
37
38/// Key of the confirmation in the input requests and responses.
39pub(crate) const CONFIRMATION_KEY: &str = "confirm";
40
41/// Everything a handler knows about the request besides its arguments.
42pub struct RequestContext {
43    pub principal: McpPrincipal,
44    pub client: ClientContext,
45    pub tool_name: &'static str,
46    /// Set when this call is the retry after a confirmation round trip.
47    pub retry: Option<RetryState>,
48    pub progress: ProgressReporter,
49}
50
51impl RequestContext {
52    /// Errors with [`ToolError::InsufficientScope`] unless the caller's token has `scope`.
53    pub fn require_scope(&self, scope: McpScope) -> Result<(), ToolError> {
54        if self.principal.has_scope(scope) {
55            Ok(())
56        } else {
57            Err(ToolError::InsufficientScope { required: scope })
58        }
59    }
60}
61
62/// A structured tool result. Hints live inside the structured object because clients drop the
63/// text content when structured content is present.
64#[derive(Debug, Clone, PartialEq)]
65pub struct ToolResult {
66    pub structured: Map<String, Value>,
67    pub hints: Vec<Hint>,
68    pub is_error: bool,
69}
70
71impl ToolResult {
72    /// `value` must serialize to a JSON object.
73    pub fn success(value: impl Serialize) -> Self {
74        let structured = match serde_json::to_value(value) {
75            Ok(Value::Object(map)) => map,
76            Ok(other) => Map::from_iter([("result".to_string(), other)]),
77            Err(error) => {
78                Map::from_iter([("serialization_error".to_string(), json!(error.to_string()))])
79            }
80        };
81        Self {
82            structured,
83            hints: Vec::new(),
84            is_error: false,
85        }
86    }
87
88    /// A failed call's result: `{"error": error}`.
89    pub fn error(error: Value) -> Self {
90        Self {
91            structured: Map::from_iter([("error".to_string(), error)]),
92            hints: Vec::new(),
93            is_error: true,
94        }
95    }
96
97    /// Adds one hint to the result.
98    pub fn with_hint(mut self, hint: Hint) -> Self {
99        self.hints.push(hint);
100        self
101    }
102
103    /// Adds several hints to the result.
104    pub fn with_hints(mut self, hints: impl IntoIterator<Item = Hint>) -> Self {
105        self.hints.extend(hints);
106        self
107    }
108
109    /// The structured object as sent: the result fields, then `hints`.
110    pub fn structured_content(&self) -> Value {
111        let mut object = self.structured.clone();
112        if !self.hints.is_empty() {
113            object.insert("hints".into(), json!(self.hints));
114        }
115        Value::Object(object)
116    }
117}
118
119/// A human decision the handler needs before it can finish, such as confirming a risky publish.
120#[derive(Debug, Clone)]
121pub struct ConfirmationRequest {
122    /// Shown to the human.
123    pub message: String,
124    /// A page on our domain where the human confirms.
125    pub url: String,
126    pub elicitation_id: String,
127    /// Returned instead when the client can't do URL elicitation: the link as text.
128    pub fallback: ToolResult,
129}
130
131/// How a handler call ended.
132#[derive(Debug, Clone)]
133pub enum ToolOutcome {
134    Complete(ToolResult),
135    NeedsConfirmation(ConfirmationRequest),
136}
137
138impl From<ToolResult> for ToolOutcome {
139    fn from(result: ToolResult) -> Self {
140        ToolOutcome::Complete(result)
141    }
142}
143
144/// Runs CPU-heavy pure work, such as an engine pass over every page, on the blocking pool, so that
145/// the worker thread keeps serving other requests meanwhile.
146pub(crate) async fn off_worker<T: Send + 'static>(
147    work: impl FnOnce() -> T + Send + 'static,
148) -> Result<T, ToolError> {
149    tokio::task::spawn_blocking(work)
150        .await
151        .map_err(|e| ToolError::Internal(format!("a blocking task failed: {e}")))
152}