Skip to content

Relax outputSchema to any JSON Schema 2020-12 document per SEP-2106#1568

Open
jayaraman-venkatesan wants to merge 4 commits into
modelcontextprotocol:mainfrom
jayaraman-venkatesan:feature/sep-2106-output-schema-relaxation
Open

Relax outputSchema to any JSON Schema 2020-12 document per SEP-2106#1568
jayaraman-venkatesan wants to merge 4 commits into
modelcontextprotocol:mainfrom
jayaraman-venkatesan:feature/sep-2106-output-schema-relaxation

Conversation

@jayaraman-venkatesan
Copy link
Copy Markdown
Contributor

@jayaraman-venkatesan jayaraman-venkatesan commented May 9, 2026

feat: relax outputSchema to any JSON Schema 2020-12 document per SEP-2106

Summary

Brings the C# SDK into compliance with SEP-2106 — Tools inputSchema & outputSchema Conform to JSON Schema 2020-12

Closes #1550

The SDK previously wrapped any non-object output schema in a {"type":"object","properties":{"result":<schema>},"required":["result"]} envelope at registration, and wrapped the corresponding return value in {"result": <value>} at every invocation. Both wrappings exist because the older MCP spec required outputSchema.type == "object". SEP-2106 lifts that restriction. This PR removes both ends of the wrapping plumbing, relaxes the validator, and lets natural JSON shapes flow through.

What changes on the wire

C# return type Before After
int Foo() => 72; schema {"type":"object","properties":{"result":{"type":"integer"}},"required":["result"]}; structuredContent {"result":72} schema {"type":"integer"}; structuredContent 72
string Foo() => "Paris"; {"result":"Paris"} "Paris"
string[] Foo() => […]; {"result":[…]} […]
Person? Foo() => null; {"result": null} null
Person Foo() => new(...); {"name":…,"age":…} (already an object — unchanged) {"name":…,"age":…} (unchanged)

inputSchema is not changed — tool inputs are still required to have type: "object", which is consistent with SEP-2106 (it loosens outputSchema only).

Source-level changes

  • src/ModelContextProtocol.Core/McpJsonUtilities.cs — added IsValidJsonSchemaDocument accepting JSON object or boolean. Kept IsValidMcpToolSchema unchanged for InputSchema (still strict).
  • src/ModelContextProtocol.Core/Protocol/Tool.csOutputSchema setter switched to IsValidJsonSchemaDocument; XML doc rewritten to reference SEP-2106 and the new contract.
  • src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs — deleted the _structuredOutputRequiresWrapping field, the out bool parameter on CreateOutputSchema, the entire wrapping/normalization block in CreateOutputSchema, the wrapping block in CreateStructuredResponse, and constructor wiring around the flag.

Contract widening

The IsValidJsonSchemaDocument validator accepts a slightly broader set than just JSON objects. The full acceptance/rejection table:

OutputSchema value Accepted? Notes
{"type":"object","properties":…} Conventional schema
{"type":"array","items":…} Array schemas — primary SEP-2106 motivation
{"type":"string"}, {"type":"integer"}, etc. Primitives
{"type":["object","null"], …} Type-arrays for nullable schemas
{} "No constraints" object schema
{"oneOf":[…]} Compositions without explicit type
true JSON Schema 2020-12 §4.3 boolean schema — "matches anything"
false JSON Schema 2020-12 §4.3 boolean schema — "matches nothing"
null literal Throws ArgumentException
Bare numbers, strings, arrays Throws ArgumentException

Behavioural break call-out

Source-breaking for tools that returned non-object types and whose downstream consumers relied on the {"result": …} envelope. After this PR the same tool emits the raw value. Per SEP-2106 §"Backward Compatibility": servers that emit array/primitive structuredContent and want to stay interoperable with old clients SHOULD also emit a TextContent block with the serialized JSON.

The SDK doesn't auto-add such a block, that remains the tool author's choice.

End-to-end smoke test

Beyond unit tests, I built a small in-process client/server demo against this branch that prints both phases of MCP exchange (advertising and invocation) for tools whose return types span the interesting shapes — int, string, string[], nullable record, plain record. Side-by-side before/after tables of what came out are captured in post-2106.md of the demo repo.

The demo wires up McpServer and McpClient over a System.IO.Pipelines pipe, registers tools via McpServerTool.Create(...) against this branch's <ProjectReference>, and prints outputSchema from tools/list followed by structuredContent from each tools/call. It confirms the wire shape end-to-end against a real client, not just unit-test mocks.

Did not implement

  • inputSchema validation - explicitly unchanged per SEP-2106.
  • JSON Schema 2020-12 keyword-level structural validation - IsValidJsonSchemaDocument is intentionally only a structural check (object or boolean). No $ref resolution, no oneOf/anyOf keyword validation.
  • Auto-emitting a TextContent mirror of structured content for old-client interop - per SEP-2106 that's a server-author choice, not an SDK default.

Test plan

  • dotnet test tests/ModelContextProtocol.Tests (net10.0) — 1904/0/5.
  • TDD red→green confirmed for all flipped/new tests.
  • End-to-end demo (jayaraman-venkatesan/sep-2106-demo) shows expected wire shapes.

@jayaraman-venkatesan
Copy link
Copy Markdown
Contributor Author

@mikekistler

can you help review this PR

}

private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping)
private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For backward compatibility reasons, I don't think we can completely eliminate the wrapping of non-object schemas, since this will still be needed for clients using 2025-11-25 or earlier protocol versions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrapping for non-object schemas is restored in c30725c, just at the wire emission boundary instead of inside CreateOutputSchema. The classification + transformation logic is the same as before SEP-2106; it moved from registration-time storage to per-request emission so a single registered Tool can serve both legacy and SEP-2106 clients off the same server

// with keywords like "type", "properties", etc.) or a boolean (`true` matches anything,
// `false` matches nothing). Stricter keyword-level validation is intentionally not
// performed.
internal static bool IsValidJsonSchemaDocument(JsonElement element) =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this method should be named IsValidToolOutputSchema, since it is designed specifically to validate tool output schemas and not any JSON Schema document.

I think we may also need to make this conditional on the protocol version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the function.

About version control here, I did not make that change to keep tools contain schema per SEP-2106. But serving of schema on tools/call and tools/list make the decision of wrapping depending on negotiated protocol version

Copy link
Copy Markdown
Contributor

@mikekistler mikekistler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this contribution!

This generally looks good but I think we may need to retain the behavior of prior protocol versions for backward compatibility.

@jayaraman-venkatesan
Copy link
Copy Markdown
Contributor Author

I will make a change to consider negotiated protocol version

…hape and rename outputSchema validator

  - Wire-side back-compat at tools/list and tools/call: clients negotiating a
    protocol version older than 2026-06-30 still receive the
    {type:object,properties:{result:<schema>}} envelope for non-object
    output schemas (and type:[object,null] normalized to object); 2026-06-30+
    clients receive the natural JSON Schema 2020-12 document. Gated per-session
    via McpSessionHandler.SupportsNaturalOutputSchemas. In-memory schema storage
    stays natural per SEP-2106.

  - Rename McpJsonUtilities.IsValidJsonSchemaDocument → IsValidToolOutputSchema.
    Tool.OutputSchema exception message and XML doc updated to reflect that
    JSON Schema 2020-12 boolean schemas (true/false per §4.3) are also accepted.

  - 10 new Theory tests across the two wire emission boundaries:
    - tests/.../McpServerToolTests.cs: 5 Theories for tools/call structuredContent
      value wrapping (string/int/array/object/nullable object × legacy/DRAFT/new
      protocol versions) plus a pinned existing invocation test.
    - tests/.../Sep2106ListToolsBackCompatTests.cs (new): 5 Theories for
      tools/list outputSchema shaping across the same protocol-version axis
@jayaraman-venkatesan
Copy link
Copy Markdown
Contributor Author

Thanks @mikekistler ! I tried to fix your comments on c30725c.

What clients now see

  • Clients on protocol versions older than 2026-06-30 (2025-11-25 and earlier) see exactly the wire shape they saw before this PR: non-object output schemas wrapped in {"type":"object","properties":{"result":<schema>}}, type:["object","null"] normalized to "object", and the matching {"result": <value>} envelope on structuredContent.
  • Clients on 2026-06-30 and later (DRAFT-2026-06-v1 and 2026-06-30) see the natural JSON Schema 2020-12 document per SEP-2106.

How

  • Tool.OutputSchema storage stays natural — the spec-correct form is what kept in memory.
  • The version-conditional reshaping happens at two wire emission sites:
    McpServerImpl's listToolsHandler (for tools/list) and
    AIFunctionMcpServerTool.CreateStructuredResponse (for tools/call).
  • Both gate on a new sibling helperMcpSessionHandler.SupportsNaturalOutputSchemas(string?) — same pattern as
    the existing SupportsPrimingEvent.

Why
We could in principle store both schemas per Tool, but we don't need to. The wrapped form is fully derivable from the natural one. Given the natural schema, the envelope around it is deterministic — same construction every time. There's no information in the wrapped shape that the natural shape doesn't carry. So I considered storing both would be redundant.

Validator rename

  • IsValidJsonSchemaDocumentIsValidToolOutputSchema per your inline. Exception message and XML doc updated to also mention JSON Schema 2020-12 boolean schemas (true/false per §4.3), which SEP-2106 accepts.

Verification

  • End-to-end demo run against the released ModelContextProtocol 1.2.0 package and against this branch with the same unpinned client. Output is byte-identical, so older clients see exactly what they used to.

Please help review the changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SEP-2106: Tools inputSchema/outputSchema conform to JSON Schema 2020-12

2 participants