Skip to main content

rmcp/handler/server/
common.rs

1//! Common utilities shared between tool and prompt handlers
2
3use std::{
4    any::TypeId,
5    collections::HashMap,
6    sync::{Arc, LazyLock},
7};
8
9use schemars::JsonSchema;
10
11use crate::{
12    RoleServer, model::JsonObject, schemars::generate::SchemaSettings, service::RequestContext,
13};
14
15/// Generates a JSON schema for a type
16pub fn schema_for_type<T: JsonSchema + std::any::Any>() -> Arc<JsonObject> {
17    thread_local! {
18        static CACHE_FOR_TYPE: std::sync::RwLock<HashMap<TypeId, Arc<JsonObject>>> = Default::default();
19    };
20    CACHE_FOR_TYPE.with(|cache| {
21        if let Some(x) = cache
22            .read()
23            .expect("schema cache lock poisoned")
24            .get(&TypeId::of::<T>())
25        {
26            x.clone()
27        } else {
28            // explicitly to align json schema version to official specifications.
29            // refer to https://github.com/modelcontextprotocol/modelcontextprotocol/pull/655 for details.
30            let settings = SchemaSettings::draft2020_12();
31            // Note: AddNullable is intentionally NOT used here because the `nullable` keyword
32            // is an OpenAPI 3.0 extension, not part of JSON Schema 2020-12. Using it would
33            // cause validation failures with strict JSON Schema validators.
34            let generator = settings.into_generator();
35            let schema = generator.into_root_schema_for::<T>();
36            let object = serde_json::to_value(schema).expect("failed to serialize schema");
37            let serde_json::Value::Object(object) = object else {
38                panic!(
39                    "Schema serialization produced non-object value: expected JSON object but got {object:?}"
40                );
41            };
42            let schema = Arc::new(object);
43            cache
44                .write()
45                .expect("schema cache lock poisoned")
46                .insert(TypeId::of::<T>(), schema.clone());
47
48            schema
49        }
50    })
51}
52
53/// Validate that the schema root is `type: "object"` (per MCP spec) and strip top-level
54/// `title`/`description` (the wrapper type name and doc, which are noise to the LLM).
55fn validate_and_strip(raw: &Arc<JsonObject>, purpose: &str) -> Result<Arc<JsonObject>, String> {
56    match raw.get("type") {
57        Some(serde_json::Value::String(t)) if t == "object" => {
58            let mut object = raw.as_ref().clone();
59            object.remove("title");
60            object.remove("description");
61            Ok(Arc::new(object))
62        }
63        Some(serde_json::Value::String(t)) => Err(format!(
64            "MCP specification requires tool {purpose} to have root type 'object', but found '{t}'."
65        )),
66        None => Err(format!(
67            "Schema is missing 'type' field. MCP specification requires {purpose} to have root type 'object'."
68        )),
69        Some(other) => Err(format!(
70            "Schema 'type' field has unexpected format: {other:?}. Expected \"object\"."
71        )),
72    }
73}
74
75/// Generate, validate, and strip a JSON schema for inputSchema (must have root type "object";
76/// top-level "title" and "description" are removed).
77pub fn schema_for_input<T: JsonSchema + std::any::Any>() -> Result<Arc<JsonObject>, String> {
78    thread_local! {
79        static CACHE_FOR_INPUT: std::sync::RwLock<HashMap<TypeId, Result<Arc<JsonObject>, String>>> = Default::default();
80    };
81    CACHE_FOR_INPUT.with(|cache| {
82        if let Some(result) = cache
83            .read()
84            .expect("input schema cache lock poisoned")
85            .get(&TypeId::of::<T>())
86        {
87            return result.clone();
88        }
89        let result = validate_and_strip(&schema_for_type::<T>(), "inputSchema");
90        cache
91            .write()
92            .expect("input schema cache lock poisoned")
93            .insert(TypeId::of::<T>(), result.clone());
94        result
95    })
96}
97
98/// Schema used when input is empty.
99pub fn schema_for_empty_input() -> Arc<JsonObject> {
100    static EMPTY: LazyLock<Arc<JsonObject>> = LazyLock::new(|| {
101        let mut object = JsonObject::new();
102        object.insert("type".into(), serde_json::json!("object"));
103        object.insert("properties".into(), serde_json::json!({}));
104        Arc::new(object)
105    });
106    EMPTY.clone()
107}
108
109/// Strip top-level `title` and `description` from a JSON schema for outputSchema.
110/// Unlike `validate_and_strip`, this performs no validation — output schemas are not
111/// restricted to `type: "object"` (per SEP-2106).
112fn strip_output(raw: &Arc<JsonObject>) -> Arc<JsonObject> {
113    let mut object = raw.as_ref().clone();
114    object.remove("title");
115    object.remove("description");
116    Arc::new(object)
117}
118
119/// Generate and strip a JSON schema for outputSchema (top-level "title" and
120/// "description" are removed; output schemas are not restricted to root type "object").
121pub fn schema_for_output<T: JsonSchema + std::any::Any>() -> Arc<JsonObject> {
122    thread_local! {
123        static CACHE_FOR_OUTPUT: std::sync::RwLock<HashMap<TypeId, Arc<JsonObject>>> = Default::default();
124    };
125
126    CACHE_FOR_OUTPUT.with(|cache| {
127        if let Some(schema) = cache
128            .read()
129            .expect("output schema cache lock poisoned")
130            .get(&TypeId::of::<T>())
131        {
132            return schema.clone();
133        }
134
135        let schema = strip_output(&schema_for_type::<T>());
136
137        cache
138            .write()
139            .expect("output schema cache lock poisoned")
140            .insert(TypeId::of::<T>(), schema.clone());
141
142        schema
143    })
144}
145
146/// Trait for extracting parts from a context, unifying tool and prompt extraction
147pub trait FromContextPart<C>: Sized {
148    fn from_context_part(context: &mut C) -> Result<Self, crate::ErrorData>;
149}
150
151/// Common extractors that can be used by both tool and prompt handlers
152impl<C> FromContextPart<C> for RequestContext<RoleServer>
153where
154    C: AsRequestContext,
155{
156    fn from_context_part(context: &mut C) -> Result<Self, crate::ErrorData> {
157        Ok(context.as_request_context().clone())
158    }
159}
160
161impl<C> FromContextPart<C> for tokio_util::sync::CancellationToken
162where
163    C: AsRequestContext,
164{
165    fn from_context_part(context: &mut C) -> Result<Self, crate::ErrorData> {
166        Ok(context.as_request_context().ct.clone())
167    }
168}
169
170impl<C> FromContextPart<C> for crate::model::Extensions
171where
172    C: AsRequestContext,
173{
174    fn from_context_part(context: &mut C) -> Result<Self, crate::ErrorData> {
175        Ok(context.as_request_context().extensions.clone())
176    }
177}
178
179#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
180pub struct Extension<T>(pub T);
181
182impl<C, T> FromContextPart<C> for Extension<T>
183where
184    C: AsRequestContext,
185    T: Send + Sync + 'static + Clone,
186{
187    fn from_context_part(context: &mut C) -> Result<Self, crate::ErrorData> {
188        let extension = context
189            .as_request_context()
190            .extensions
191            .get::<T>()
192            .cloned()
193            .ok_or_else(|| {
194                crate::ErrorData::invalid_params(
195                    format!("missing extension {}", std::any::type_name::<T>()),
196                    None,
197                )
198            })?;
199        Ok(Extension(extension))
200    }
201}
202
203impl<C> FromContextPart<C> for crate::Peer<RoleServer>
204where
205    C: AsRequestContext,
206{
207    fn from_context_part(context: &mut C) -> Result<Self, crate::ErrorData> {
208        Ok(context.as_request_context().peer.clone())
209    }
210}
211
212impl<C> FromContextPart<C> for crate::model::RequestMetaObject
213where
214    C: AsRequestContext,
215{
216    fn from_context_part(context: &mut C) -> Result<Self, crate::ErrorData> {
217        let request_context = context.as_request_context_mut();
218        let mut meta = crate::model::RequestMetaObject::default();
219        std::mem::swap(&mut meta, &mut request_context.meta);
220        Ok(meta)
221    }
222}
223
224#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
225pub struct RequestId(pub crate::model::RequestId);
226
227impl<C> FromContextPart<C> for RequestId
228where
229    C: AsRequestContext,
230{
231    fn from_context_part(context: &mut C) -> Result<Self, crate::ErrorData> {
232        Ok(RequestId(context.as_request_context().id.clone()))
233    }
234}
235
236/// Trait for types that can provide access to RequestContext
237pub trait AsRequestContext {
238    fn as_request_context(&self) -> &RequestContext<RoleServer>;
239    fn as_request_context_mut(&mut self) -> &mut RequestContext<RoleServer>;
240}
241
242#[cfg(test)]
243mod tests {
244    use rstest::rstest;
245
246    use super::*;
247
248    #[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
249    struct TestObject {
250        value: i32,
251    }
252
253    #[derive(serde::Serialize, serde::Deserialize, JsonSchema)]
254    struct AnotherTestObject {
255        value: i32,
256    }
257
258    #[rstest]
259    #[case::primitive(schema_for_type::<i32>, "integer")]
260    #[case::array(schema_for_type::<Vec<i32>>, "array")]
261    #[case::struct_object(schema_for_type::<TestObject>, "object")]
262    fn schema_for_type_sets_expected_root_type(
263        #[case] schema_fn: fn() -> Arc<JsonObject>,
264        #[case] expected_type: &str,
265    ) {
266        let schema = schema_fn();
267
268        assert_eq!(schema.get("type"), Some(&serde_json::json!(expected_type)));
269    }
270
271    #[test]
272    fn schema_for_type_sets_array_item_type() {
273        let schema = schema_for_type::<Vec<i32>>();
274        let items = schema.get("items").and_then(|v| v.as_object()).unwrap();
275
276        assert_eq!(items.get("type"), Some(&serde_json::json!("integer")));
277    }
278
279    #[test]
280    fn schema_for_type_sets_struct_properties() {
281        let schema = schema_for_type::<TestObject>();
282        let properties = schema
283            .get("properties")
284            .and_then(|v| v.as_object())
285            .unwrap();
286
287        assert!(properties.contains_key("value"));
288    }
289
290    #[rstest]
291    #[case::primitive(schema_for_type::<i32>)]
292    #[case::struct_object(schema_for_type::<TestObject>)]
293    fn test_schema_for_type_caches_schemas(#[case] schema_fn: fn() -> Arc<JsonObject>) {
294        let schema1 = schema_fn();
295        let schema2 = schema_fn();
296
297        assert!(Arc::ptr_eq(&schema1, &schema2));
298    }
299
300    #[test]
301    fn test_schema_for_type_different_types_different_schemas() {
302        let schema1 = schema_for_type::<TestObject>();
303        let schema2 = schema_for_type::<AnotherTestObject>();
304
305        assert!(!Arc::ptr_eq(&schema1, &schema2));
306    }
307
308    #[test]
309    fn test_schema_for_type_arc_can_be_shared() {
310        let schema = schema_for_type::<TestObject>();
311        let cloned = schema.clone();
312
313        assert!(Arc::ptr_eq(&schema, &cloned));
314    }
315
316    #[test]
317    fn test_schema_for_output_accepts_primitive() {
318        let schema = schema_for_output::<i32>();
319        assert_eq!(schema.get("type"), Some(&serde_json::json!("integer")));
320    }
321
322    #[test]
323    fn test_schema_for_output_strips_description_for_primitive() {
324        let schema = schema_for_output::<i32>();
325        assert!(!schema.contains_key("description"));
326    }
327
328    #[test]
329    fn test_schema_for_output_accepts_composition() {
330        let schema = schema_for_output::<Option<String>>();
331        let schema_str = serde_json::to_string(&schema).unwrap();
332        assert!(
333            schema_str.contains("anyOf")
334                || schema_str.contains("oneOf")
335                || schema_str.contains("null"),
336            "Expected composition schema for Option<String>, got: {schema_str}"
337        );
338    }
339
340    #[test]
341    fn test_schema_for_output_caches_result() {
342        let schema1 = schema_for_output::<i32>();
343        let schema2 = schema_for_output::<i32>();
344        assert!(Arc::ptr_eq(&schema1, &schema2));
345    }
346
347    #[test]
348    fn test_schema_for_input_rejects_array() {
349        let result = schema_for_input::<Vec<i32>>();
350        assert!(result.is_err());
351    }
352
353    #[test]
354    fn test_schema_for_output_accepts_unit() {
355        let _schema = schema_for_output::<()>();
356    }
357
358    #[test]
359    fn test_schema_for_output_accepts_object() {
360        let schema = schema_for_output::<TestObject>();
361        assert_eq!(schema.get("type"), Some(&serde_json::json!("object")));
362    }
363
364    #[test]
365    fn test_schema_for_output_strips_top_level_title() {
366        let schema = schema_for_output::<TestObject>();
367        assert!(!schema.contains_key("title"));
368    }
369
370    #[test]
371    fn test_schema_for_output_strips_top_level_description() {
372        let schema = schema_for_output::<TestObject>();
373        assert!(!schema.contains_key("description"));
374    }
375
376    #[rstest]
377    #[case::input(schema_for_input::<i32>)]
378    fn test_schema_for_input_rejects_primitives(
379        #[case] schema_fn: fn() -> Result<Arc<JsonObject>, String>,
380    ) {
381        let result = schema_fn();
382        assert!(result.is_err());
383    }
384
385    #[rstest]
386    #[case::input(schema_for_input::<TestObject>)]
387    fn test_schema_for_input_accepts_objects(
388        #[case] schema_fn: fn() -> Result<Arc<JsonObject>, String>,
389    ) {
390        let result = schema_fn();
391        assert!(result.is_ok());
392    }
393
394    #[rstest]
395    #[case::input_title(schema_for_input::<TestObject>, "title")]
396    #[case::input_description(schema_for_input::<TestObject>, "description")]
397    fn test_schema_for_input_strips_top_level_metadata(
398        #[case] schema_fn: fn() -> Result<Arc<JsonObject>, String>,
399        #[case] field: &str,
400    ) {
401        let schema = schema_fn().unwrap();
402        assert!(!schema.contains_key(field));
403    }
404}