Skip to content

Commit 2113a98

Browse files
committed
test(openapi): add test and docs for nullable field schema workaround (modelcontextprotocol#135)
1 parent 9d6f9a2 commit 2113a98

File tree

3 files changed

+158
-1
lines changed

3 files changed

+158
-1
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,6 @@ See [examples](examples/README.md)
206206

207207
## Development with Dev Container
208208
See [docs/DEVCONTAINER.md](docs/DEVCONTAINER.md) for instructions on using Dev Container for development.
209+
210+
## Workarounds
211+
See [docs/WORKAROUNDS.md](docs/WORKAROUNDS.md) for common issues.

crates/rmcp/tests/test_tool_macros.rs

+85-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::sync::Arc;
22

33
use rmcp::{ServerHandler, handler::server::tool::ToolCallContext, tool};
4-
use schemars::JsonSchema;
4+
use schemars::{JsonSchema, schema_for};
55
use serde::{Deserialize, Serialize};
66

77
#[derive(Serialize, Deserialize, JsonSchema)]
@@ -100,3 +100,87 @@ async fn test_tool_macros_with_generics() {
100100
}
101101

102102
impl GetWeatherRequest {}
103+
104+
#[test]
105+
fn test_optional_field_schema_generation() {
106+
// tests https://github.com/modelcontextprotocol/rust-sdk/issues/135
107+
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
108+
pub struct DefaultOptionalSchema {
109+
#[schemars(description = "An optional description field")]
110+
pub description: Option<String>,
111+
}
112+
113+
let schema = schema_for!(DefaultOptionalSchema);
114+
let schema_json = serde_json::to_value(&schema).unwrap();
115+
116+
// Print the actual generated schema for debugging
117+
println!(
118+
"Actual schema for DefaultOptionalSchema: {:#?}",
119+
schema_json
120+
);
121+
122+
// Verify the default schema generation for Option<String>
123+
// This format (`type: [T, 'null']`) is reported as incompatible with some clients (Cursor, Windsurf).
124+
let description_schema = &schema_json["properties"]["description"];
125+
assert_eq!(
126+
description_schema["type"],
127+
serde_json::json!(["string", "null"]),
128+
"Default schema for Option<String> should be type: [\"string\", \"null\"] as per schemars default"
129+
);
130+
// Ensure 'nullable' field is NOT present at this level in the default schema
131+
assert!(
132+
description_schema.get("nullable").is_none(),
133+
"Default schema should not have top-level 'nullable' field"
134+
);
135+
136+
// We still check the description is correct
137+
assert_eq!(
138+
description_schema["description"],
139+
"An optional description field"
140+
);
141+
142+
// --- Demonstrate the workaround ---
143+
144+
// Custom schema function for the workaround for https://github.com/modelcontextprotocol/rust-sdk/issues/135
145+
pub fn nullable_string_schema(
146+
_: &mut schemars::r#gen::SchemaGenerator,
147+
) -> schemars::schema::Schema {
148+
serde_json::from_value(serde_json::json!({
149+
"type": "string",
150+
"nullable": true,
151+
// Note: Description needs to be reapplied here if using schema_with
152+
"description": "An optional description field (workaround)"
153+
}))
154+
.unwrap()
155+
}
156+
157+
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
158+
pub struct WorkaroundOptionalSchema {
159+
#[schemars(
160+
schema_with = "nullable_string_schema",
161+
description = "An optional description field (workaround)" // Description here is mainly for docs, schema_with overrides generated schema description
162+
)]
163+
#[serde(default)] // Needed for deserialization when field is absent
164+
pub description: Option<String>,
165+
}
166+
167+
let schema_workaround = schema_for!(WorkaroundOptionalSchema);
168+
let schema_workaround_json = serde_json::to_value(&schema_workaround).unwrap();
169+
170+
// Verify the workaround schema generation
171+
let description_schema_workaround = &schema_workaround_json["properties"]["description"];
172+
assert_eq!(
173+
description_schema_workaround["type"],
174+
serde_json::json!("string"),
175+
"Workaround schema for Option<String> should be type: \"string\""
176+
);
177+
assert_eq!(
178+
description_schema_workaround["nullable"],
179+
serde_json::json!(true),
180+
"Workaround schema for Option<String> should have nullable: true"
181+
);
182+
assert_eq!(
183+
description_schema_workaround["description"],
184+
"An optional description field (workaround)"
185+
);
186+
}

docs/WORKAROUNDS.md

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Workarounds
2+
3+
## Optional Field Schema Compatibility (OpenAPI 3.0 vs 3.1 Style)
4+
5+
### Reason
6+
7+
The `schemars` crate, used for generating JSON schemas for tool parameters, defaults to representing optional fields (like `Option<String>`) using the `type: ["string", "null"]` format. This is valid according to the OpenAPI 3.0 specification.
8+
9+
However, some clients or tools (e.g., Cursor, Windsurf, tools expecting OpenAPI 3.1+ style schemas) might expect optional fields to be represented differently, typically using `type: "string", nullable: true`.
10+
11+
This difference in representation can lead to compatibility issues where the client fails to correctly interpret the schema for optional parameters.
12+
13+
### Workaround
14+
15+
To ensure maximum compatibility, you can explicitly instruct `schemars` to generate the `type: "T", nullable: true` format using a combination of `#[schemars(schema_with = ...)]` and `#[serde(default)]`.
16+
17+
1. **Define a helper function** that generates the desired schema format:
18+
19+
```rust
20+
// Place this in a shared module or utils file
21+
pub fn nullable_string_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
22+
serde_json::from_value(serde_json::json!({
23+
"type": "string",
24+
"nullable": true,
25+
// Note: The description from the field's #[schemars] attribute
26+
// needs to be manually added here if desired in the final schema.
27+
// Alternatively, omit it if the description from the attribute is sufficient.
28+
// "description": "An optional description field (workaround)"
29+
})).unwrap()
30+
}
31+
32+
// Add similar functions for other types like Option<i64>, Option<bool> etc. if needed
33+
pub fn nullable_i64_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
34+
serde_json::from_value(serde_json::json!({
35+
"type": "integer",
36+
"format": "int64",
37+
"nullable": true
38+
})).unwrap()
39+
}
40+
```
41+
42+
2. **Apply the attributes** to your `Option<T>` field:
43+
44+
```rust
45+
use schemars::JsonSchema;
46+
use serde::{Deserialize, Serialize};
47+
// Assuming nullable_string_schema is in scope
48+
// use path::to::nullable_string_schema;
49+
50+
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
51+
pub struct UpdateTaskRequest {
52+
#[schemars(
53+
schema_with = "nullable_string_schema",
54+
description = "A new short description of the task" // This description is still useful for documentation generation
55+
)]
56+
#[serde(default)] // Needed for correct deserialization when the field is absent
57+
pub description: Option<String>,
58+
}
59+
```
60+
61+
This forces the generation of the more widely compatible schema format for the optional field.
62+
63+
### Status
64+
65+
- Tracked in issue: [https://github.com/modelcontextprotocol/rust-sdk/issues/135](https://github.com/modelcontextprotocol/rust-sdk/issues/135)
66+
67+
68+
69+
70+

0 commit comments

Comments
 (0)