Skip to content

Non-object outputSchema and structuredContent are sent to clients that negotiated 2025-06-18 / 2025-11-25, where an object is required #1337

Description

@d3ep4k

Summary

A tool that returns Json<Vec<T>> (or any non-object type) makes the server advertise a non-object outputSchema, and return a non-object structuredContent, to every client, including clients that negotiated protocol 2025-06-18 or 2025-11-25. In those versions both must be objects, so such a client sees a schema that is invalid for the version it asked for.

This is not a request to undo SEP-2106 (#874); allowing arrays and primitives is right for 2026-07-28. The gap is that the choice does not depend on the negotiated version, although other 2026-07-28 behaviour in handler/server.rs is gated on it.

What the spec versions say

  • 2025-06-18, schema.ts: outputSchema?: { type: "object"; properties?: ...; required?: string[] }
  • 2025-11-25, schema.ts: outputSchema?: { $schema?: string; type: "object"; ... }, and structuredContent?: { [key: string]: unknown }
  • 2026-07-28 (SEP-2106): any JSON Schema 2020-12 schema, any JSON value.

crates/rmcp/src/handler/server/common.rs (strip_output) says output schemas "are not restricted to type: "object" (per SEP-2106)" unconditionally.

Reproduction

rmcp 3.5.1, features = ["server", "macros", "transport-io"].

use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::model::{Implementation, ServerCapabilities, ServerConfig};
use rmcp::{tool, tool_handler, tool_router, Json, ServerHandler, ServiceExt};

#[derive(Clone)]
struct Server { tool_router: ToolRouter<Self> }

#[tool_router]
impl Server {
    #[tool(description = "Returns a list of names")]
    async fn names(&self) -> Json<Vec<String>> {
        Json(vec!["a".to_string(), "b".to_string()])
    }
}

#[tool_handler]
impl ServerHandler for Server {
    fn get_info(&self) -> ServerConfig {
        ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
            .with_server_info(Implementation::new("repro", "0.1.0"))
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let server = Server { tool_router: Server::tool_router() };
    server.serve(rmcp::transport::stdio()).await.unwrap().waiting().await.unwrap();
}

A client that asks for 2025-06-18:

{ printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"names","arguments":{}}}'; sleep 1; } | ./target/debug/repro

Output (trimmed):

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18", ...}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"names", ...,
  "outputSchema":{"$schema":"https://json-schema.org/draft/2020-12/schema","items":{"type":"string"},"type":"array"}}]}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"[\"a\",\"b\"]"}],"structuredContent":["a","b"],"isError":false}}

The server answers protocolVersion: 2025-06-18 and then advertises outputSchema.type: "array" and returns structuredContent: ["a","b"]. The same happens when the client asks for 2025-11-25. When I asked for 2026-07-28 in initialize, the server answered 2025-11-25 and still advertised the array schema.

Why it matters

A client that validates tools/list against the schema of the negotiated version will reject the tool, and some validate the whole list at once, so one tool with a list result can make every tool unavailable. We hit this shipping an MCP server (metamug/plunger#97); the report there was that the output schema was not an object. I have not checked which specific clients reject it, only that the advertised schema is invalid for the version that was negotiated.

The workaround is to wrap every list in a struct (as suggested in #532), which works everywhere, but it is easy to miss because Json<Vec<T>> compiles and works with clients that are lenient or new.

Suggested behaviour

Any of these would help, in rough order of preference:

  1. For a session that negotiated a version before 2026-07-28, omit outputSchema when its root type is not "object" (the call still works; the result is also in content), and do the same for structuredContent. This follows the version gating already used for result_type.
  2. Offer a wrapper (for example Json<Wrapped<T>>, or an attribute on #[tool]) that produces { "result": T } and an object schema in all versions, so authors have a supported way to be compatible without writing a struct for every list.
  3. At minimum, document on Json<T> and on schema_for_output that a non-object T is only valid for peers on 2026-07-28 and later.

Related: #532 (the same symptom, closed before SEP-2106), #874 (SEP-2106).

Activity

  1. added
    bugSomething is not working
    P1High: significant functionality gap or spec violation
    T-handlerHandler implementation changes
    T-modelModel/data structure changes
    ready for workIssue is well-defined and ready to be picked up
    on Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High: significant functionality gap or spec violationT-handlerHandler implementation changesT-modelModel/data structure changesbugSomething is not workingready for workIssue is well-defined and ready to be picked up

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions