Skip to content

Fix $ref pointer resolution after output schema wrapping#1435

Open
weinong wants to merge 1 commit intomodelcontextprotocol:mainfrom
weinong:fix/rewrite-ref-after-output-schema-wrapping
Open

Fix $ref pointer resolution after output schema wrapping#1435
weinong wants to merge 1 commit intomodelcontextprotocol:mainfrom
weinong:fix/rewrite-ref-after-output-schema-wrapping

Conversation

@weinong
Copy link

@weinong weinong commented Mar 15, 2026

Summary

  • Fix: After CreateOutputSchema() wraps a non-object schema under properties.result, rewrite all internal $ref JSON Pointers so they remain valid at their new location.
  • Test: Add StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers to verify the fix.

Problem

System.Text.Json's JsonSchemaExporter deduplicates repeated types by emitting $ref pointers with absolute JSON Pointer paths (e.g., #/items/properties/foo/items). When CreateOutputSchema() wraps a non-object schema inside { "type": "object", "properties": { "result": <original> } }, these $ref paths become unresolvable because the original schema has moved under #/properties/result.

This breaks all tools that:

  1. Return a non-object type (IEnumerable<T>, List<T>, arrays, primitives)
  2. Have UseStructuredContent = true
  3. Have a return type graph containing the same type at multiple locations (triggering $ref deduplication)

When a client calls tools/list, the broken schema causes JSON Schema validation errors. In MCP clients that don't isolate per-tool errors (e.g., the TypeScript SDK's cacheToolMetadata()), one broken tool schema prevents all tools from being enumerated.

Fix

Add RewriteRefPointers() — a recursive walk over the wrapped JSON tree that prepends /properties/result to every $ref value starting with #/. Called immediately after the wrapping block in CreateOutputSchema().

Reproduction

A minimal repro is available as a gist: https://gist.github.com/weinong/7a750e9b99c846dadc4fa41fc6c856fc

Fixes #1434

When CreateOutputSchema() wraps a non-object schema under
properties.result, internal $ref JSON Pointers emitted by
System.Text.Json's JsonSchemaExporter become unresolvable because they
still point to the original (unwrapped) paths.

Add RewriteRefPointers() to prepend /properties/result to every $ref
that starts with #/, keeping the pointers valid after wrapping.

Fixes modelcontextprotocol#1434
@weinong weinong force-pushed the fix/rewrite-ref-after-output-schema-wrapping branch from 2b469d0 to 9c68a58 Compare March 15, 2026 18:15
@stephentoub stephentoub requested a review from Copilot March 15, 2026 19:59
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes structured output JSON Schema generation for tools that return non-object types by ensuring $ref JSON Pointers remain valid after the schema is wrapped under properties.result (addresses #1434).

Changes:

  • Add RewriteRefPointers to rewrite internal $ref values (#/… and #) after non-object output schemas are wrapped under properties.result.
  • Add tests that validate wrapped structured output schemas still validate and that all $ref pointers resolve.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Rewrites $ref JSON Pointer paths after wrapping non-object output schemas under properties.result.
tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs Adds coverage for duplicated-type and recursive-type $ref scenarios to ensure rewritten pointers are resolvable post-wrapping.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +634 to +657
AssertAllRefsStartWith(schemaNode, "#/properties/result");
AssertAllRefsResolvable(schemaNode, schemaNode);
}

private static void AssertAllRefsStartWith(JsonNode? node, string expectedPrefix)
{
if (node is JsonObject obj)
{
if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
refNode?.GetValue<string>() is string refValue)
{
Assert.StartsWith(expectedPrefix, refValue);
}

foreach (var property in obj)
{
AssertAllRefsStartWith(property.Value, expectedPrefix);
}
}
else if (node is JsonArray arr)
{
foreach (var item in arr)
{
AssertAllRefsStartWith(item, expectedPrefix);
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.

CreateOutputSchema wraps non-object schemas without rewriting internal $ref pointers

2 participants