|
// Reproduction for: CreateOutputSchema wraps non-object schemas without rewriting $ref pointers |
|
// https://github.com/modelcontextprotocol/csharp-sdk/issues/XXX |
|
// |
|
// This program demonstrates the bug by: |
|
// 1. Creating an MCP tool that returns IEnumerable<T> where T contains duplicate types |
|
// 2. Showing the generated outputSchema has broken $ref pointers after wrapping |
|
// |
|
// Expected: $ref pointers should be rewritten to account for the wrapper |
|
// Actual: $ref pointers point to non-existent paths, breaking schema validation |
|
|
|
using System.ComponentModel; |
|
using System.Text.Json; |
|
using ModelContextProtocol.Server; |
|
|
|
// === Models with duplicate type usage (triggers path-based $ref) === |
|
|
|
public class PhoneNumber |
|
{ |
|
[Description("The label for this phone number (e.g., 'Mobile', 'Work')")] |
|
public string? Label { get; set; } |
|
|
|
[Description("The phone number")] |
|
public string? Number { get; set; } |
|
} |
|
|
|
public class ContactMechanism |
|
{ |
|
[Description("List of phone numbers")] |
|
public List<PhoneNumber>? PhoneNumbers { get; set; } // First occurrence - schema defined here |
|
|
|
[Description("List of SMS-capable numbers")] |
|
public List<PhoneNumber>? SmsNumbers { get; set; } // Second occurrence - uses $ref to PhoneNumbers |
|
} |
|
|
|
public class Contact |
|
{ |
|
[Description("Contact name")] |
|
public string? Name { get; set; } |
|
|
|
[Description("Contact mechanisms")] |
|
public ContactMechanism? ContactMechanism { get; set; } |
|
} |
|
|
|
// === Tool class === |
|
|
|
public class ContactTools |
|
{ |
|
/// <summary> |
|
/// This tool returns IEnumerable<Contact>, which: |
|
/// 1. Has root type "array" (not "object") |
|
/// 2. Contains ContactMechanism with two List<PhoneNumber> properties |
|
/// |
|
/// System.Text.Json generates a $ref for SmsNumbers pointing to PhoneNumbers: |
|
/// "#/items/properties/contactMechanism/properties/phoneNumbers/items" |
|
/// |
|
/// CreateOutputSchema wraps this in { "type": "object", "properties": { "result": ... } } |
|
/// but does NOT rewrite the $ref, so it becomes unresolvable. |
|
/// </summary> |
|
[McpServerTool(UseStructuredContent = true)] |
|
[Description("Get all contacts with their phone numbers and SMS numbers")] |
|
public Task<IEnumerable<Contact>> GetContacts() |
|
{ |
|
return Task.FromResult<IEnumerable<Contact>>(new List<Contact> |
|
{ |
|
new Contact |
|
{ |
|
Name = "John Doe", |
|
ContactMechanism = new ContactMechanism |
|
{ |
|
PhoneNumbers = new List<PhoneNumber> |
|
{ |
|
new PhoneNumber { Label = "Mobile", Number = "+1-555-1234" } |
|
}, |
|
SmsNumbers = new List<PhoneNumber> |
|
{ |
|
new PhoneNumber { Label = "Mobile", Number = "+1-555-1234" } |
|
} |
|
} |
|
} |
|
}); |
|
} |
|
} |
|
|
|
// === Main: Extract and display the broken schema === |
|
|
|
public class Program |
|
{ |
|
public static void Main(string[] args) |
|
{ |
|
Console.WriteLine("=== MCP C# SDK $ref Rewriting Bug Reproduction ===\n"); |
|
|
|
// Create the tool using the SDK's mechanism |
|
var method = typeof(ContactTools).GetMethod(nameof(ContactTools.GetContacts))!; |
|
var toolInstance = new ContactTools(); |
|
var tool = McpServerTool.Create(method, toolInstance, new McpServerToolCreateOptions()); |
|
|
|
// Get the protocol tool which contains the outputSchema |
|
var protocolTool = tool.ProtocolTool; |
|
|
|
Console.WriteLine($"Tool name: {protocolTool.Name}"); |
|
Console.WriteLine($"Description: {protocolTool.Description}\n"); |
|
|
|
if (protocolTool.OutputSchema is JsonElement schema) |
|
{ |
|
var options = new JsonSerializerOptions { WriteIndented = true }; |
|
var schemaJson = JsonSerializer.Serialize(schema, options); |
|
|
|
Console.WriteLine("Generated outputSchema:"); |
|
Console.WriteLine(schemaJson); |
|
Console.WriteLine(); |
|
|
|
// Check for the broken $ref |
|
var schemaStr = schemaJson; |
|
if (schemaStr.Contains("\"$ref\"")) |
|
{ |
|
Console.WriteLine("⚠️ Schema contains $ref pointers. Analyzing...\n"); |
|
|
|
// Find all $ref values |
|
using var doc = JsonDocument.Parse(schemaJson); |
|
var refs = FindAllRefs(doc.RootElement); |
|
|
|
foreach (var refPath in refs) |
|
{ |
|
Console.WriteLine($"Found $ref: {refPath}"); |
|
|
|
// Check if the $ref is resolvable |
|
if (refPath.StartsWith("#/items")) |
|
{ |
|
Console.WriteLine($" ❌ BROKEN: Path starts with #/items but schema root is type:object"); |
|
Console.WriteLine($" The schema was wrapped, but $ref was not rewritten."); |
|
Console.WriteLine($" Should be: #/properties/result{refPath[1..]}"); |
|
} |
|
else if (refPath.StartsWith("#/properties/result")) |
|
{ |
|
Console.WriteLine($" ✅ OK: Path correctly references wrapped location"); |
|
} |
|
else |
|
{ |
|
Console.WriteLine($" ⚠️ Unknown pattern"); |
|
} |
|
Console.WriteLine(); |
|
} |
|
} |
|
else |
|
{ |
|
Console.WriteLine("✅ No $ref pointers found in schema (bug not triggered)"); |
|
Console.WriteLine(" This can happen if the model graph has no duplicate types."); |
|
} |
|
} |
|
else |
|
{ |
|
Console.WriteLine("❌ No outputSchema generated"); |
|
} |
|
|
|
Console.WriteLine("\n=== What happens when a client validates this schema ===\n"); |
|
Console.WriteLine("MCP clients (like the TypeScript SDK) compile JSON Schema validators"); |
|
Console.WriteLine("for each tool's outputSchema. When AJV encounters the broken $ref:"); |
|
Console.WriteLine(); |
|
Console.WriteLine(" MissingRefError: can't resolve reference"); |
|
Console.WriteLine(" #/items/properties/contactMechanism/properties/phoneNumbers/items"); |
|
Console.WriteLine(" from id #"); |
|
Console.WriteLine(); |
|
Console.WriteLine("Because there's no per-tool error isolation, this crashes the entire"); |
|
Console.WriteLine("tools/list operation, making ALL tools unreachable."); |
|
} |
|
|
|
static List<string> FindAllRefs(JsonElement element, string path = "") |
|
{ |
|
var refs = new List<string>(); |
|
|
|
if (element.ValueKind == JsonValueKind.Object) |
|
{ |
|
foreach (var prop in element.EnumerateObject()) |
|
{ |
|
if (prop.Name == "$ref" && prop.Value.ValueKind == JsonValueKind.String) |
|
{ |
|
refs.Add(prop.Value.GetString()!); |
|
} |
|
else |
|
{ |
|
refs.AddRange(FindAllRefs(prop.Value, $"{path}/{prop.Name}")); |
|
} |
|
} |
|
} |
|
else if (element.ValueKind == JsonValueKind.Array) |
|
{ |
|
int i = 0; |
|
foreach (var item in element.EnumerateArray()) |
|
{ |
|
refs.AddRange(FindAllRefs(item, $"{path}[{i}]")); |
|
i++; |
|
} |
|
} |
|
|
|
return refs; |
|
} |
|
} |