flymcprust_core/extension.rs
1//! Protocol extensions (for example `io.modelcontextprotocol/tasks` or
2//! `io.modelcontextprotocol/ui`).
3//!
4//! Extensions are negotiated through capabilities: the server advertises
5//! them in `server/discover`, and a client lists the ones it supports in each
6//! request's `_meta`. An extension may only change behavior for requests
7//! whose client declared it ([`Ctx::client_supports_extension`]); otherwise
8//! the server falls back to core behavior, as the specification requires.
9//!
10//! This first version covers advertisement only. Extension-defined methods,
11//! result types and result interception will be added to this trait without
12//! changes to the core request pipeline (see `docs/ARCHITECTURE.md` ยง6.6).
13//!
14//! [`Ctx::client_supports_extension`]: crate::Ctx::client_supports_extension
15
16use serde_json::{Map, Value};
17
18/// A protocol extension supported by the server.
19pub trait Extension: Send + Sync + 'static {
20 /// The extension identifier, e.g. `io.modelcontextprotocol/tasks`. It
21 /// must have a vendor prefix (`{prefix}/{name}`).
22 fn id(&self) -> &str;
23
24 /// The settings object advertised in `capabilities.extensions`. An empty
25 /// object means "supported, no settings".
26 fn settings(&self) -> Map<String, Value> {
27 Map::new()
28 }
29}
30
31/// Checks the identifier format: a dotted prefix of labels, a slash, and a
32/// non-empty name (the `_meta` key rules with a mandatory prefix).
33pub(crate) fn is_valid_id(id: &str) -> bool {
34 let Some((prefix, name)) = id.split_once('/') else {
35 return false;
36 };
37 let label_ok = |label: &str| {
38 let mut chars = label.chars();
39 let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
40 let last_ok = label
41 .chars()
42 .last()
43 .is_some_and(|c| c.is_ascii_alphanumeric());
44 first_ok && last_ok && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
45 };
46 let name_ok = !name.is_empty()
47 && name.starts_with(|c: char| c.is_ascii_alphanumeric())
48 && name.ends_with(|c: char| c.is_ascii_alphanumeric())
49 && name
50 .chars()
51 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
52 prefix.split('.').all(label_ok) && name_ok
53}
54
55#[cfg(test)]
56mod tests {
57 use super::is_valid_id;
58
59 #[test]
60 fn extension_ids() {
61 assert!(is_valid_id("io.modelcontextprotocol/tasks"));
62 assert!(is_valid_id("com.example/my-extension"));
63 assert!(!is_valid_id("tasks"));
64 assert!(!is_valid_id("com.example/"));
65 assert!(!is_valid_id("1com.example/x"));
66 }
67}