Skip to content

Instantly share code, notes, and snippets.

@weinong
Created March 15, 2026 17:36
Show Gist options
  • Select an option

  • Save weinong/7a750e9b99c846dadc4fa41fc6c856fc to your computer and use it in GitHub Desktop.

Select an option

Save weinong/7a750e9b99c846dadc4fa41fc6c856fc to your computer and use it in GitHub Desktop.
Repro: MCP C# SDK CreateOutputSchema wraps non-object schemas without rewriting $ref pointers

MCP C# SDK $ref Rewriting Bug Reproduction

This is a minimal reproduction for the bug where CreateOutputSchema() wraps non-object schemas without rewriting internal $ref JSON Pointers.

The Bug

When a tool method returns IEnumerable<T> (or any non-object type) with UseStructuredContent = true, the SDK:

  1. Generates a JSON Schema with root type: "array"
  2. Wraps it in { "type": "object", "properties": { "result": <original> } }
  3. Does NOT rewrite $ref pointers that pointed to paths under the original root

If the type graph contains duplicate types (e.g., List<PhoneNumber> used twice), System.Text.Json emits $ref pointers like #/items/properties/.../items. After wrapping, these should become #/properties/result/items/properties/.../items, but they don't — leaving broken, unresolvable references.

Running the Repro

cd repro-ref-bug
dotnet run

Expected Output

The program will show the generated outputSchema and highlight the broken $ref:

=== MCP C# SDK $ref Rewriting Bug Reproduction ===

Tool name: GetContacts
Description: Get all contacts with their phone numbers and SMS numbers

Generated outputSchema:
{
  "type": "object",
  "properties": {
    "result": {
      "type": "array",
      "items": {
        ...
      }
    }
  },
  "required": ["result"]
}

⚠️  Schema contains $ref pointers. Analyzing...

Found $ref: #/items/properties/contactMechanism/properties/phoneNumbers/items
  ❌ BROKEN: Path starts with #/items but schema root is type:object
     The schema was wrapped, but $ref was not rewritten.
     Should be: #/properties/result/items/properties/contactMechanism/properties/phoneNumbers/items

Impact

When any MCP client calls tools/list and validates output schemas, it fails:

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

This crashes the entire tool enumeration, making all tools unreachable.

Fix

In AIFunctionMcpServerTool.cs, after wrapping the schema (line 399), walk the JSON tree and rewrite all $ref values starting with #/ to prepend /properties/result.

// 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;
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.2" />
</ItemGroup>
</Project>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment