Skip to content

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

@weinong

Description

@weinong

Describe the bug

When a tool method returns a non-object type (e.g., IEnumerable<T>, List<T>), CreateOutputSchema() wraps the generated JSON Schema in an envelope object with a result property to satisfy the MCP spec requirement that outputSchema root be type: "object". However, the wrapping does not rewrite internal $ref JSON Pointers.

Any $ref that pointed to a path under the original schema root (e.g., #/items/...) becomes unresolvable after the schema is moved under #/properties/result/.... This causes MCP clients that validate output schemas (including the official TypeScript SDK) to crash during tools/list, making all tools on the server unreachable — not just the one with the broken schema.

Root cause

Part 1 — System.Text.Json generates path-based $ref for duplicate types: When JsonSchemaExporter serializes a type graph containing the same type at multiple locations, it avoids duplication by emitting a $ref pointing to the first occurrence using an absolute JSON Pointer from the schema root. For example, a class with two List<PhoneNumber> properties will have the second property's items reference the first via $ref.

Part 2 — CreateOutputSchema() wraps without rewriting $ref paths: The method at AIFunctionMcpServerTool.cs:386-400 moves the original schema under properties.result:

schemaNode = new JsonObject
{
    ["type"] = "object",
    ["properties"] = new JsonObject
    {
        ["result"] = schemaNode   // original schema moved here
    },
    ["required"] = new JsonArray { (JsonNode)"result" }
};

After wrapping, #/items/... should become #/properties/result/items/..., but $ref values are not rewritten.

To Reproduce

Minimal model + tool:

public class PhoneNumber
{
    public string? Label { get; set; }
    public string? Number { get; set; }
}

public class ContactMechanism
{
    public List<PhoneNumber>? PhoneNumbers { get; set; }  // first occurrence
    public List<PhoneNumber>? SmsNumbers { get; set; }    // second occurrence → $ref
}

public class Contact
{
    public string? Name { get; set; }
    public ContactMechanism? ContactMechanism { get; set; }
}

public class ContactTools
{
    [McpServerTool(UseStructuredContent = true)]
    [Description("Get contacts")]
    public Task<IEnumerable<Contact>> GetContacts()
    {
        return Task.FromResult<IEnumerable<Contact>>(new List<Contact>());
    }
}

Standalone repro program (just dotnet run): https://gist.github.com/weinong/7a750e9b99c846dadc4fa41fc6c856fc

Output showing the broken $ref:

Generated outputSchema:
{
  "type": "object",
  "properties": {
    "result": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "name": { ... },
          "contactMechanism": {
            "properties": {
              "phoneNumbers": {
                "items": {
                  "type": "object",
                  "properties": {
                    "label": { "type": ["string", "null"] },
                    "number": { "type": ["string", "null"] }
                  }
                }
              },
              "smsNumbers": {
                "items": {
                  "$ref": "#/items/properties/contactMechanism/properties/phoneNumbers/items"
                }          ^^^^^ BROKEN — #/items doesn't exist at root after wrapping
              }
            }
          }
        }
      }
    }
  },
  "required": ["result"]
}

Client-side error (from TypeScript MCP SDK / AJV):

MissingRefError: can't resolve reference
  #/items/properties/contactMechanism/properties/phoneNumbers/items
  from id #

Expected behavior

After wrapping, all internal $ref pointers starting with #/ should be rewritten to prepend /properties/result, e.g.:

  • Before: #/items/properties/contactMechanism/properties/phoneNumbers/items
  • After: #/properties/result/items/properties/contactMechanism/properties/phoneNumbers/items

Conditions for triggering

All three must be met:

  1. Tool returns a non-object type (IEnumerable<T>, List<T>, arrays, primitives)
  2. UseStructuredContent = true
  3. The return type graph contains a duplicate type at multiple locations (causing System.Text.Json to emit a path-based $ref)

Suggested fix

After wrapping the schema (line 399), walk the JSON tree and rewrite $ref values:

RewriteRefs(schemaNode);

static void RewriteRefs(JsonNode? node)
{
    if (node is JsonObject obj)
    {
        if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
            refNode?.GetValue<string>() is string refValue &&
            refValue.StartsWith("#/"))
        {
            obj["$ref"] = "#/properties/result" + refValue[1..];
        }
        foreach (var kvp in obj.ToList())
            RewriteRefs(kvp.Value);
    }
    else if (node is JsonArray arr)
    {
        foreach (var item in arr)
            RewriteRefs(item);
    }
}

Alternatively, configure JsonSchemaExporterOptions to use $defs for type deduplication instead of inline path-based $ref.

Environment

  • SDK version: ModelContextProtocol 0.4.0-preview.2
  • Runtime: .NET 9.0
  • Schema generator: System.Text.Json JsonSchemaExporter
  • Client: TypeScript MCP SDK with AJV 8.18.0

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions