-
Notifications
You must be signed in to change notification settings - Fork 663
Description
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:
- Tool returns a non-object type (
IEnumerable<T>,List<T>, arrays, primitives) UseStructuredContent = true- The return type graph contains a duplicate type at multiple locations (causing
System.Text.Jsonto 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.JsonJsonSchemaExporter - Client: TypeScript MCP SDK with AJV 8.18.0