Skip to content

Optional Fields in rust-sdk MCP Schemas (type: ["T", "null"]) Cause Client Incompatibility #135

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
aitoroses opened this issue Apr 16, 2025 · 8 comments
Labels
bug Something isn't working

Comments

@aitoroses
Copy link

aitoroses commented Apr 16, 2025

In the rust-sdk MCP implementation, when using schemars to generate OpenAPI/JSON schemas for structs with optional fields (e.g., Option<String>, Option<i64>, etc.), the default output is type: ["T", "null"] (where T is the underlying type). While this is technically correct per the OpenAPI spec, it leads to incompatibility with some clients and tools, such as Cursor and Windsurf, which expect a different representation for optional fields.

To work around this, I had to combine #[serde(default)] with a custom schema using #[schemars(schema_with = "nullable_string_schema", description = "A new short description of the task")] to ensure compatibility. This workaround adds boilerplate and is not ideal for maintainability.


What doesn't work (default approach):

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UpdateTaskRequest {
    #[schemars(description = "A new short description of the task")]
    pub description: Option<String>,
}

Generated schema:

{
  "description": "A new short description of the task",
  "type": ["string", "null"]
}

Problem:
Some clients (e.g., Cursor, Windsurf) do not accept this representation for optional fields.


What works (workaround):

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UpdateTaskRequest {
    #[schemars(
        schema_with = "nullable_string_schema",
        description = "A new short description of the task"
    )]
    #[serde(default)]
    pub description: Option<String>,
}

// Custom schema function
pub fn nullable_string_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
    serde_json::from_value(serde_json::json!({
        "type": "string",
        "nullable": true,
        "description": "A new short description of the task"
    })).unwrap()
}

Generated schema:

{
  "type": "string",
  "nullable": true,
  "description": "A new short description of the task"
}

This is accepted by more clients.


Steps to Reproduce

  1. In the rust-sdk MCP implementation, define a struct with an Option<T> field and derive JsonSchema.
  2. Generate the schema using schemars.
  3. Observe that the field is represented as type: ["T", "null"].
  4. Attempt to use the schema with clients like Cursor or Windsurf and note the incompatibility.

Expected Behavior

Schemas for optional fields should be compatible with a wide range of clients, or there should be clear documentation or configuration options for this scenario.

Actual Behavior

The default schema output is not accepted by some clients, requiring custom workarounds.

Request

  • Investigate the compatibility issue with type: ["T", "null"] in popular clients for the rust-sdk MCP implementation.
  • Consider providing a built-in, ergonomic way to generate schemas for optional fields that maximizes compatibility.
  • Update documentation to clarify best practices for optional fields and client compatibility in the context of rust-sdk MCP.

Thank you!

@aitoroses aitoroses added the bug Something isn't working label Apr 16, 2025
@Hendler
Copy link
Contributor

Hendler commented Apr 17, 2025

Ideally, schemars itself would offer a configuration option to generate the type: "T", nullable: true style (or the equivalent OpenAPI 3.1 representation if targeting that). This would be the most ergonomic solution. You might consider opening an issue or checking the schemars repository for existing discussions on this topic.

Failing an upstream fix, the we could potentially provide a helper attribute or modify its #[tool] macro processing to automatically apply the schema_with workaround for Option<T> fields. However, this adds complexity and might hide the underlying schemars behavior. For now, the explicit workaround seems preferable.

PR to document here meanwhile #137

@jokemanfire
Copy link
Collaborator

I'm considering that if we wrap a schemars macro will be greater?
@Hendler what's your opinion?

@Hendler
Copy link
Contributor

Hendler commented Apr 17, 2025

@jokemanfire , I feel like we want to be compatible, but if we create a macro for this workaround we are adding kruft.
For now, the PR I have tests for this case and I think that can be merged so that if there are changes there's a test for regression. If you believe it's better to create a macro, I think @aitoroses shared his thoughts there too.

@ssddOnTop
Copy link
Contributor

ssddOnTop commented Apr 18, 2025

I think that's not an official spec. Arrays for typedef are not supported.

We can either:

  1. Add a feature in schemars crate to be able to define JSON schema according to OpenAPI spec.
  2. Avoid Optional field, use #[serde(skip_serializing_if = "...")]
  3. Try to have a custom impl of traits provided by schemars

@Hendler
Copy link
Contributor

Hendler commented Apr 18, 2025

Googling I see settings can be set for schemars and SchemaSettings::openapi3() could be used? https://docs.rs/schemars/latest/src/schemars/gen.rs.html#85-105

Don't know if that breaks things for other clients? We expose configuration?

or

use schemars::{schema_for, JsonSchema};
use schemars::gen::{SchemaGenerator, SchemaSettings};

#[derive(JsonSchema)]
pub struct UpdateTaskRequest {
    pub description: Option<String>,
}

let settings = SchemaSettings::default()
    .with(|s| {
        s.option_nullable = true;        // emit `nullable: true`
        s.option_add_null_type = false;  // don’t emit `type: ["T","null"]`
    });
let mut generator: SchemaGenerator = settings.into();
let schema = generator.into_root_schema_for::<UpdateTaskRequest>();
println!("{}", serde_json::to_string_pretty(&schema).unwrap());

@Hendler
Copy link
Contributor

Hendler commented Apr 18, 2025

I've tested the Schemasettings::openapi3() and that seems to work in this PR #137. Does some one want to confirm this solves the problem? Do we need for rmcp model as well?

Hendler added a commit to HumanAssisted/rust-sdk that referenced this issue Apr 18, 2025
@aitoroses
Copy link
Author

aitoroses commented Apr 19, 2025

Thanks @Hendler for the recent work and discussion on this! I’ve tested the latest branch with SchemaSettings::openapi3() and can confirm that it does solve the OpenAPI schema generation issue for Option<T> fields—the generated schema now uses "type": "T", "nullable": true", which is accepted by clients like Cursor and Windsurf.
However, there is still a runtime issue:

  • When sending a payload with a field set to null (e.g., "parent_id": null) for an Option<i64> parameter, the MCP tool system returns an error:
    Parameter 'parent_id' must be of type integer,null, got object
    
  • This happens even though the Rust struct is defined as Option<i64>, with #[serde(default)] and the correct schema.
  • The same error occurs if the field is omitted from the payload.

@Hendler
Copy link
Contributor

Hendler commented Apr 19, 2025

@aitoroses can you show some code to reproduce? Is it a new error or existed before?

The exact error you mention is a the serde error, but not sure where exactly it is triggered.

edit: This test passes, but maybe I'm not replicating the issue?

Hendler added a commit to HumanAssisted/rust-sdk that referenced this issue Apr 20, 2025
Hendler added a commit to HumanAssisted/rust-sdk that referenced this issue Apr 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

4 participants