mcpkit_server/context.rs
1//! Request context for MCP handlers.
2//!
3//! The context provides access to the current request state and allows
4//! handlers to interact with the connection (sending notifications,
5//! progress updates, etc.).
6//!
7//! # Key Features
8//!
9//! - **Borrowing-friendly**: Uses lifetime references, NO `'static` requirement
10//! - **Progress reporting**: Send progress updates for long-running operations
11//! - **Cancellation**: Check if the request has been cancelled
12//! - **Notifications**: Send notifications back to the client via Peer trait
13//!
14//! # Example
15//!
16//! ```rust
17//! use mcpkit_server::{Context, NoOpPeer, ContextData};
18//! use mcpkit_core::capability::{ClientCapabilities, ServerCapabilities};
19//! use mcpkit_core::protocol::RequestId;
20//! use mcpkit_core::protocol_version::ProtocolVersion;
21//!
22//! // Create test context data
23//! let data = ContextData::new(
24//! RequestId::Number(1),
25//! ClientCapabilities::default(),
26//! ServerCapabilities::default(),
27//! ProtocolVersion::LATEST,
28//! );
29//! let peer = NoOpPeer;
30//!
31//! // Create a context from the data
32//! let ctx = Context::new(
33//! &data.request_id,
34//! data.progress_token.as_ref(),
35//! &data.client_caps,
36//! &data.server_caps,
37//! data.protocol_version,
38//! &peer,
39//! );
40//!
41//! // Check for cancellation and protocol version
42//! assert!(!ctx.is_cancelled());
43//! assert!(ctx.protocol_version.supports_tasks());
44//! ```
45
46use mcpkit_core::capability::{ClientCapabilities, ServerCapabilities};
47use mcpkit_core::error::McpError;
48use mcpkit_core::protocol::{Notification, ProgressToken, RequestId, Response};
49use mcpkit_core::protocol_version::ProtocolVersion;
50use mcpkit_core::types::elicitation::{ElicitRequest, ElicitResult, UrlElicitRequest};
51use mcpkit_core::types::logging::{LoggingLevel, LoggingMessageNotificationParams};
52use mcpkit_core::types::notifications::ProgressNotificationParams;
53use mcpkit_core::types::roots::{ListRootsResult, Root};
54use mcpkit_core::types::sampling::{CreateMessageRequest, CreateMessageResult};
55use mcpkit_core::types::task::TaskId;
56use std::borrow::Cow;
57use std::future::Future;
58use std::pin::Pin;
59
60/// Trait for sending messages to the peer (client or server).
61///
62/// This trait abstracts over the transport layer, allowing the context
63/// to send notifications without knowing the underlying transport.
64pub trait Peer: Send + Sync {
65 /// Send a notification to the peer.
66 fn notify(
67 &self,
68 notification: Notification,
69 ) -> Pin<Box<dyn Future<Output = Result<(), McpError>> + Send + '_>>;
70
71 /// Send a request to the peer and await its response.
72 ///
73 /// Used for server-initiated requests such as elicitation and sampling. The
74 /// implementation assigns the request id and correlates the response.
75 ///
76 /// The default implementation returns an error: a peer that has no
77 /// persistent bidirectional connection (for example a one-shot HTTP
78 /// response) cannot make server-initiated requests.
79 fn request(
80 &self,
81 method: Cow<'static, str>,
82 params: Option<serde_json::Value>,
83 ) -> Pin<Box<dyn Future<Output = Result<Response, McpError>> + Send + '_>> {
84 let _ = (method, params);
85 Box::pin(async {
86 Err(McpError::internal(
87 "this peer does not support server-initiated requests",
88 ))
89 })
90 }
91}
92
93// The cancellation token is shared with the client-side task machinery and
94// lives in `mcpkit_core::tasks`; re-exported here for path stability.
95pub use mcpkit_core::tasks::{CancellationToken, CancelledFuture};
96
97/// Request context passed to handler methods.
98///
99/// The context uses lifetime references to avoid `'static` requirements
100/// and Arc overhead. This enables:
101/// - Single-threaded async without Arc overhead
102/// - `!Send` types in handlers (important for some runtimes)
103/// - Users who need spawning can wrap in Arc themselves
104///
105/// Per the plan: "Request context - passed by reference, NO 'static requirement"
106pub struct Context<'a> {
107 /// The request ID for this operation.
108 pub request_id: &'a RequestId,
109 /// Optional progress token for reporting progress.
110 pub progress_token: Option<&'a ProgressToken>,
111 /// Client capabilities negotiated during initialization.
112 pub client_caps: &'a ClientCapabilities,
113 /// Server capabilities advertised during initialization.
114 pub server_caps: &'a ServerCapabilities,
115 /// The negotiated protocol version.
116 ///
117 /// Use this to check version-specific feature availability via
118 /// methods like `supports_tasks()`, `supports_elicitation()`, etc.
119 pub protocol_version: ProtocolVersion,
120 /// Peer for sending notifications.
121 peer: &'a dyn Peer,
122 /// Cancellation token for this request.
123 cancel: CancellationToken,
124 /// The task this request is executing as part of, if any.
125 ///
126 /// Set by [`run_augmented_tool`](crate::router::run_augmented_tool) for a
127 /// task-augmented tool call. When present, every outbound request this
128 /// context makes carries `io.modelcontextprotocol/related-task` in its
129 /// `_meta`, which the spec requires of task-related messages.
130 related_task: Option<TaskId>,
131}
132
133/// Sentinel [`RequestId`] for notification-scoped contexts (see
134/// [`Context::for_notification`]). Notifications have no request id; this is not
135/// a real JSON-RPC id.
136static NOTIFICATION_REQUEST_ID: std::sync::LazyLock<RequestId> =
137 std::sync::LazyLock::new(|| RequestId::String("__notification__".to_string()));
138
139impl<'a> Context<'a> {
140 /// Create a new context with all required references.
141 #[must_use]
142 pub fn new(
143 request_id: &'a RequestId,
144 progress_token: Option<&'a ProgressToken>,
145 client_caps: &'a ClientCapabilities,
146 server_caps: &'a ServerCapabilities,
147 protocol_version: ProtocolVersion,
148 peer: &'a dyn Peer,
149 ) -> Self {
150 Self {
151 request_id,
152 progress_token,
153 client_caps,
154 server_caps,
155 protocol_version,
156 peer,
157 cancel: CancellationToken::new(),
158 related_task: None,
159 }
160 }
161
162 /// Create a new context with a custom cancellation token.
163 #[must_use]
164 pub fn with_cancellation(
165 request_id: &'a RequestId,
166 progress_token: Option<&'a ProgressToken>,
167 client_caps: &'a ClientCapabilities,
168 server_caps: &'a ServerCapabilities,
169 protocol_version: ProtocolVersion,
170 peer: &'a dyn Peer,
171 cancel: CancellationToken,
172 ) -> Self {
173 Self {
174 request_id,
175 progress_token,
176 client_caps,
177 server_caps,
178 protocol_version,
179 peer,
180 cancel,
181 related_task: None,
182 }
183 }
184
185 /// Create a context for handling an inbound client notification.
186 ///
187 /// Notifications carry no JSON-RPC request id, so
188 /// [`request_id`](Self::request_id) is a documented sentinel
189 /// (`__notification__`) that must **not** be treated as a real id. The
190 /// context is still outbound-capable: a hook may call
191 /// [`list_roots`](Self::list_roots) or send notifications, and those
192 /// server-to-client requests allocate their own ids via the peer.
193 #[must_use]
194 pub fn for_notification(
195 client_caps: &'a ClientCapabilities,
196 server_caps: &'a ServerCapabilities,
197 protocol_version: ProtocolVersion,
198 peer: &'a dyn Peer,
199 ) -> Self {
200 Self {
201 request_id: &NOTIFICATION_REQUEST_ID,
202 progress_token: None,
203 client_caps,
204 server_caps,
205 protocol_version,
206 peer,
207 cancel: CancellationToken::new(),
208 related_task: None,
209 }
210 }
211
212 /// Associate this context with a task.
213 ///
214 /// Every outbound request made through it then carries
215 /// `_meta["io.modelcontextprotocol/related-task"]` with `task_id`, which
216 /// the spec requires of messages related to a task:
217 ///
218 /// > All requests, notifications, and responses related to a task **MUST**
219 /// > include the `io.modelcontextprotocol/related-task` key in their
220 /// > `_meta` field […] an elicitation that a task-augmented tool call
221 /// > depends on **MUST** share the same related task ID with that tool
222 /// > call's task.
223 ///
224 /// Applied automatically by the task-augmented tool path; call this only
225 /// when driving a task yourself.
226 #[must_use]
227 pub fn with_related_task(mut self, task_id: TaskId) -> Self {
228 self.related_task = Some(task_id);
229 self
230 }
231
232 /// The task this request is part of, if any.
233 #[must_use]
234 pub const fn related_task(&self) -> Option<&TaskId> {
235 self.related_task.as_ref()
236 }
237
238 /// Stamp the related-task `_meta` onto outbound request params, if this
239 /// context belongs to a task.
240 fn tag_related_task(&self, params: serde_json::Value) -> serde_json::Value {
241 match self.related_task.as_ref() {
242 Some(id) => mcpkit_core::tasks::inject_related_task(params, id),
243 None => params,
244 }
245 }
246
247 /// Check if the request has been cancelled.
248 #[must_use]
249 pub fn is_cancelled(&self) -> bool {
250 self.cancel.is_cancelled()
251 }
252
253 /// Get a future that completes when the request is cancelled.
254 pub fn cancelled(&self) -> impl Future<Output = ()> + '_ {
255 self.cancel.cancelled()
256 }
257
258 /// Get the cancellation token for this context.
259 #[must_use]
260 pub const fn cancellation_token(&self) -> &CancellationToken {
261 &self.cancel
262 }
263
264 /// Send a notification to the client.
265 ///
266 /// # Arguments
267 ///
268 /// * `method` - The notification method name
269 /// * `params` - Optional notification parameters
270 ///
271 /// # Errors
272 ///
273 /// Returns an error if the notification could not be sent.
274 pub async fn notify(
275 &self,
276 method: &str,
277 params: Option<serde_json::Value>,
278 ) -> Result<(), McpError> {
279 let notification = if let Some(p) = params {
280 Notification::with_params(method.to_string(), p)
281 } else {
282 Notification::new(method.to_string())
283 };
284 self.peer.notify(notification).await
285 }
286
287 /// Report progress for this operation.
288 ///
289 /// This sends a progress notification to the client if a progress token
290 /// was provided with the request.
291 ///
292 /// # Arguments
293 ///
294 /// * `current` - Current progress value
295 /// * `total` - Total progress value (if known)
296 /// * `message` - Optional progress message
297 ///
298 /// # Errors
299 ///
300 /// Returns an error if the notification could not be sent.
301 pub async fn progress(
302 &self,
303 current: f64,
304 total: Option<f64>,
305 message: Option<&str>,
306 ) -> Result<(), McpError> {
307 let Some(token) = self.progress_token else {
308 // No progress token, silently succeed
309 return Ok(());
310 };
311
312 let params = ProgressNotificationParams {
313 total,
314 message: message.map(String::from),
315 ..ProgressNotificationParams::new(token.clone(), current)
316 };
317
318 self.notify(
319 mcpkit_core::methods::notifications::PROGRESS,
320 Some(serde_json::to_value(params)?),
321 )
322 .await
323 }
324
325 /// Emit a `notifications/message` log to the client at `level`, optionally
326 /// tagged with a `logger` name and carrying arbitrary JSON `data`.
327 ///
328 /// # Errors
329 ///
330 /// Returns an error if the notification could not be sent.
331 pub async fn log(
332 &self,
333 level: LoggingLevel,
334 logger: Option<&str>,
335 data: serde_json::Value,
336 ) -> Result<(), McpError> {
337 let params = LoggingMessageNotificationParams {
338 logger: logger.map(String::from),
339 ..LoggingMessageNotificationParams::new(level, data)
340 };
341 self.notify(
342 mcpkit_core::methods::notifications::MESSAGE,
343 Some(serde_json::to_value(params)?),
344 )
345 .await
346 }
347
348 /// Send a request to the client and await its response.
349 ///
350 /// This is the basis for server-initiated requests (e.g. elicitation,
351 /// sampling). The peer assigns the request id and correlates the response;
352 /// the request is aborted if this context is cancelled.
353 ///
354 /// # Errors
355 ///
356 /// Returns an error if the request was cancelled, the peer does not support
357 /// requests, the request timed out, or the response carried a JSON-RPC
358 /// error.
359 pub async fn request(
360 &self,
361 method: impl Into<Cow<'static, str>>,
362 params: Option<serde_json::Value>,
363 ) -> Result<serde_json::Value, McpError> {
364 use futures::future::{Either, select};
365
366 let request = self.peer.request(method.into(), params);
367 let cancelled = self.cancel.cancelled();
368 let response = match select(request, cancelled).await {
369 Either::Left((result, _)) => result?,
370 Either::Right(((), _)) => return Err(McpError::internal("request cancelled")),
371 };
372
373 if let Some(error) = response.error {
374 return Err(McpError::internal(error.message));
375 }
376 response
377 .result
378 .ok_or_else(|| McpError::internal("response contained neither result nor error"))
379 }
380
381 /// Request structured input from the user through the client (form-mode
382 /// elicitation).
383 ///
384 /// Sends an `elicitation/create` request and awaits the user's response
385 /// (accept with content, decline, or cancel). This requires the client to
386 /// have declared the `elicitation` capability and the negotiated protocol
387 /// version to support elicitation.
388 ///
389 /// # Errors
390 ///
391 /// Returns an error if the client did not declare elicitation support, the
392 /// negotiated protocol version predates elicitation, the request was
393 /// cancelled or timed out, or the response could not be parsed.
394 pub async fn elicit(&self, request: ElicitRequest) -> Result<ElicitResult, McpError> {
395 if !self.protocol_version.supports_elicitation() {
396 return Err(McpError::internal(
397 "the negotiated protocol version does not support elicitation",
398 ));
399 }
400 if !self.client_caps.has_elicitation() {
401 return Err(McpError::internal(
402 "the client did not declare the elicitation capability",
403 ));
404 }
405
406 let params = self.tag_related_task(serde_json::to_value(&request).map_err(McpError::from)?);
407 let result = self.request("elicitation/create", Some(params)).await?;
408 serde_json::from_value(result).map_err(McpError::from)
409 }
410
411 /// Request the roots this client exposes (`roots/list`).
412 ///
413 /// Requires the client to have declared the `roots` capability.
414 ///
415 /// # Errors
416 ///
417 /// Returns an error if the client did not declare roots support, or the
418 /// request fails, times out, or the response could not be parsed.
419 pub async fn list_roots(&self) -> Result<Vec<Root>, McpError> {
420 if !self.client_caps.has_roots() {
421 return Err(McpError::internal(
422 "the client did not declare the roots capability",
423 ));
424 }
425 let result = self.request("roots/list", None).await?;
426 let result: ListRootsResult = serde_json::from_value(result).map_err(McpError::from)?;
427 Ok(result.roots)
428 }
429
430 /// Request a URL-mode elicitation: ask the client to have the user navigate
431 /// to a URL for an out-of-band interaction (e.g. authorization or payment).
432 ///
433 /// Returns the client's [`ElicitResult`] action once the user consents to
434 /// open the URL. When the out-of-band interaction later finishes, notify the
435 /// client with `ServerNotifier::elicitation_complete(elicitation_id)`.
436 ///
437 /// Gated on the client's `elicitation.url` sub-capability (which is only
438 /// declared on 2025-11-25+).
439 ///
440 /// # Security
441 ///
442 /// Per the MCP spec, the caller MUST use an unguessable `elicitation_id`
443 /// bound to a verified user identity and MUST NOT place credentials in the
444 /// URL. mcpkit provides the mechanism; associating the id with a user is the
445 /// application's responsibility (see the session-binding helpers, #86).
446 ///
447 /// # Errors
448 ///
449 /// Returns an error if the negotiated protocol version does not support
450 /// elicitation, the client did not declare URL-mode elicitation, or the
451 /// request fails.
452 pub async fn elicit_url(&self, request: UrlElicitRequest) -> Result<ElicitResult, McpError> {
453 if !self.protocol_version.supports_elicitation() {
454 return Err(McpError::internal(
455 "the negotiated protocol version does not support elicitation",
456 ));
457 }
458 if !self.client_caps.has_url_elicitation() {
459 return Err(McpError::internal(
460 "the client did not declare URL-mode elicitation support",
461 ));
462 }
463
464 let params = self.tag_related_task(serde_json::to_value(&request).map_err(McpError::from)?);
465 let result = self.request("elicitation/create", Some(params)).await?;
466 serde_json::from_value(result).map_err(McpError::from)
467 }
468
469 /// Request the client to run an LLM completion (sampling).
470 ///
471 /// Sends a `sampling/createMessage` request and awaits the generated
472 /// message. This requires the client to have declared the `sampling`
473 /// capability (sampling is available in every protocol version).
474 ///
475 /// # Errors
476 ///
477 /// Returns an error if the client did not declare sampling support, the
478 /// request was cancelled or timed out, or the response could not be parsed.
479 pub async fn create_message(
480 &self,
481 request: CreateMessageRequest,
482 ) -> Result<CreateMessageResult, McpError> {
483 if !self.client_caps.has_sampling() {
484 return Err(McpError::internal(
485 "the client did not declare the sampling capability",
486 ));
487 }
488
489 let params = self.tag_related_task(serde_json::to_value(&request).map_err(McpError::from)?);
490 let result = self.request("sampling/createMessage", Some(params)).await?;
491 serde_json::from_value(result).map_err(McpError::from)
492 }
493}
494
495impl std::fmt::Debug for Context<'_> {
496 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497 f.debug_struct("Context")
498 .field("request_id", &self.request_id)
499 .field("progress_token", &self.progress_token)
500 .field("client_caps", &self.client_caps)
501 .field("server_caps", &self.server_caps)
502 .field("protocol_version", &self.protocol_version)
503 .field("is_cancelled", &self.is_cancelled())
504 .finish()
505 }
506}
507
508/// A no-op peer implementation for testing.
509///
510/// This peer silently accepts all notifications without sending them anywhere.
511#[derive(Debug, Clone, Copy)]
512pub struct NoOpPeer;
513
514impl Peer for NoOpPeer {
515 fn notify(
516 &self,
517 _notification: Notification,
518 ) -> Pin<Box<dyn Future<Output = Result<(), McpError>> + Send + '_>> {
519 Box::pin(async { Ok(()) })
520 }
521}
522
523/// Owned data for creating contexts.
524///
525/// This struct holds owned copies of all the data needed to create a Context.
526/// It's useful when you need to create contexts from owned data.
527pub struct ContextData {
528 /// The request ID.
529 pub request_id: RequestId,
530 /// Optional progress token.
531 pub progress_token: Option<ProgressToken>,
532 /// Client capabilities.
533 pub client_caps: ClientCapabilities,
534 /// Server capabilities.
535 pub server_caps: ServerCapabilities,
536 /// The negotiated protocol version.
537 pub protocol_version: ProtocolVersion,
538}
539
540impl ContextData {
541 /// Create a new context data struct.
542 #[must_use]
543 pub const fn new(
544 request_id: RequestId,
545 client_caps: ClientCapabilities,
546 server_caps: ServerCapabilities,
547 protocol_version: ProtocolVersion,
548 ) -> Self {
549 Self {
550 request_id,
551 progress_token: None,
552 client_caps,
553 server_caps,
554 protocol_version,
555 }
556 }
557
558 /// Set the progress token.
559 #[must_use]
560 pub fn with_progress_token(mut self, token: ProgressToken) -> Self {
561 self.progress_token = Some(token);
562 self
563 }
564
565 /// Create a context from this data with the given peer.
566 #[must_use]
567 pub fn to_context<'a>(&'a self, peer: &'a dyn Peer) -> Context<'a> {
568 Context::new(
569 &self.request_id,
570 self.progress_token.as_ref(),
571 &self.client_caps,
572 &self.server_caps,
573 self.protocol_version,
574 peer,
575 )
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_context_creation() {
585 let request_id = RequestId::Number(1);
586 let client_caps = ClientCapabilities::default();
587 let server_caps = ServerCapabilities::default();
588 let peer = NoOpPeer;
589
590 let ctx = Context::new(
591 &request_id,
592 None,
593 &client_caps,
594 &server_caps,
595 ProtocolVersion::LATEST,
596 &peer,
597 );
598
599 assert!(!ctx.is_cancelled());
600 assert!(ctx.progress_token.is_none());
601 assert_eq!(ctx.protocol_version, ProtocolVersion::LATEST);
602 }
603
604 #[test]
605 fn test_context_with_progress_token() {
606 let request_id = RequestId::Number(1);
607 let progress_token = ProgressToken::String("token".to_string());
608 let client_caps = ClientCapabilities::default();
609 let server_caps = ServerCapabilities::default();
610 let peer = NoOpPeer;
611
612 let ctx = Context::new(
613 &request_id,
614 Some(&progress_token),
615 &client_caps,
616 &server_caps,
617 ProtocolVersion::V2025_03_26,
618 &peer,
619 );
620
621 assert!(ctx.progress_token.is_some());
622 assert_eq!(ctx.protocol_version, ProtocolVersion::V2025_03_26);
623 }
624
625 #[test]
626 fn test_context_data() {
627 let data = ContextData::new(
628 RequestId::Number(42),
629 ClientCapabilities::default(),
630 ServerCapabilities::default(),
631 ProtocolVersion::V2025_06_18,
632 )
633 .with_progress_token(ProgressToken::String("test".to_string()));
634
635 let peer = NoOpPeer;
636 let ctx = data.to_context(&peer);
637
638 assert!(ctx.progress_token.is_some());
639 assert_eq!(ctx.protocol_version, ProtocolVersion::V2025_06_18);
640 // Test feature detection via protocol version
641 assert!(ctx.protocol_version.supports_elicitation());
642 assert!(!ctx.protocol_version.supports_tasks()); // Tasks require 2025-11-25
643 }
644
645 #[tokio::test]
646 async fn list_roots_requests_and_parses_when_advertised() {
647 use mcpkit_core::protocol::Response;
648
649 struct RootsPeer;
650 impl Peer for RootsPeer {
651 fn notify(
652 &self,
653 _n: Notification,
654 ) -> Pin<Box<dyn Future<Output = Result<(), McpError>> + Send + '_>> {
655 Box::pin(async { Ok(()) })
656 }
657 fn request(
658 &self,
659 method: Cow<'static, str>,
660 _params: Option<serde_json::Value>,
661 ) -> Pin<Box<dyn Future<Output = Result<Response, McpError>> + Send + '_>> {
662 assert_eq!(method, "roots/list");
663 let result = serde_json::to_value(ListRootsResult {
664 roots: vec![Root::new("file:///a").name("a")],
665 meta: None,
666 })
667 .unwrap();
668 Box::pin(async move { Ok(Response::success(RequestId::Number(1), result)) })
669 }
670 }
671
672 let request_id = RequestId::Number(1);
673 let server_caps = ServerCapabilities::default();
674 let peer = RootsPeer;
675
676 // Advertised -> request sent and result parsed.
677 let client_caps = ClientCapabilities::default().with_roots();
678 let ctx = Context::new(
679 &request_id,
680 None,
681 &client_caps,
682 &server_caps,
683 ProtocolVersion::LATEST,
684 &peer,
685 );
686 let roots = ctx.list_roots().await.expect("roots listed");
687 assert_eq!(roots.len(), 1);
688 assert_eq!(roots[0].name.as_deref(), Some("a"));
689
690 // Not advertised -> error before any request.
691 let no_roots = ClientCapabilities::default();
692 let ctx = Context::new(
693 &request_id,
694 None,
695 &no_roots,
696 &server_caps,
697 ProtocolVersion::LATEST,
698 &peer,
699 );
700 assert!(ctx.list_roots().await.is_err());
701 }
702}