Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
return false; // No type keyword found.
}

// Per SEP-2106, a tool's outputSchema may be any valid JSON Schema document — not just
// schemas with type:"object". Validation is therefore reduced to a structural check
// matching JSON Schema 2020-12: a schema may be either a JSON object (the usual form
// with keywords like "type", "properties", etc.) or a boolean (`true` matches anything,
// `false` matches nothing). Stricter keyword-level validation is intentionally not
// performed.
internal static bool IsValidJsonSchemaDocument(JsonElement element) =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this method should be named IsValidToolOutputSchema, since it is designed specifically to validate tool output schemas and not any JSON Schema document.

I think we may also need to make this conditional on the protocol version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed the function.

About version control here, I did not make that change to keep tools contain schema per SEP-2106. But serving of schema on tools/call and tools/list make the decision of wrapping depending on negotiated protocol version

element.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False;

// Keep in sync with CreateDefaultOptions above.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Expand Down
19 changes: 11 additions & 8 deletions src/ModelContextProtocol.Core/Protocol/Tool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,20 @@ public JsonElement InputSchema
} = McpJsonUtilities.DefaultMcpToolSchema;

/// <summary>
/// Gets or sets a JSON Schema object defining the expected structured outputs for the tool.
/// Gets or sets a JSON Schema document describing the shape of the tool's structured output.
/// </summary>
/// <exception cref="ArgumentException">The value is not a valid MCP tool JSON schema.</exception>
/// <exception cref="ArgumentException">The value is not a JSON object.</exception>
/// <remarks>
/// <para>
/// The schema must be a valid JSON Schema object with the "type" property set to "object".
/// This is enforced by validation in the setter which will throw an <see cref="ArgumentException"/>
/// if an invalid schema is provided.
/// Per SEP-2106 ("Allow valid JSON Schemas in <c>outputSchema</c>"), the schema may describe
/// any JSON value — object, array, string, number, boolean, or <see langword="null"/> — to
/// support tools whose structured output is not an object. The setter only validates that the
/// supplied value is a JSON object (a schema document); deeper keyword-level validation is
/// intentionally not performed.
/// </para>
/// <para>
/// The schema should describe the shape of the data as returned in <see cref="CallToolResult.StructuredContent"/>.
/// The schema describes the shape of the value placed in <see cref="CallToolResult.StructuredContent"/>.
/// Unlike <see cref="InputSchema"/>, the top-level <c>type</c> is no longer required to be <c>"object"</c>.
/// </para>
/// </remarks>
[JsonPropertyName("outputSchema")]
Expand All @@ -99,9 +102,9 @@ public JsonElement? OutputSchema
get => field;
set
{
if (value is not null && !McpJsonUtilities.IsValidMcpToolSchema(value.Value))
if (value is not null && !McpJsonUtilities.IsValidJsonSchemaDocument(value.Value))
{
throw new ArgumentException("The specified document is not a valid MCP tool output JSON schema.", nameof(OutputSchema));
throw new ArgumentException("The specified document is not a valid JSON Schema document (must be a JSON object).", nameof(OutputSchema));
}

field = value;
Expand Down
77 changes: 13 additions & 64 deletions src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ namespace ModelContextProtocol.Server;
/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
private readonly bool _structuredOutputRequiresWrapping;
private readonly IReadOnlyList<object> _metadata;

/// <summary>
Expand Down Expand Up @@ -120,7 +119,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
Name = options?.Name ?? function.Name,
Description = GetToolDescription(function, options),
InputSchema = function.JsonSchema,
OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping),
OutputSchema = CreateOutputSchema(function, options),
Icons = options?.Icons,
};

Expand Down Expand Up @@ -167,7 +166,7 @@ options.OpenWorld is not null ||
tool.Execution.TaskSupport = ToolTaskSupport.Optional;
}

return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []);
return new AIFunctionMcpServerTool(function, tool, options?.Services, options?.Metadata ?? []);
}

private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options)
Expand Down Expand Up @@ -235,14 +234,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
internal AIFunction AIFunction { get; }

/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList<object> metadata)
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, IReadOnlyList<object> metadata)
{
ValidateToolName(tool.Name);

AIFunction = function;
ProtocolTool = tool;

_structuredOutputRequiresWrapping = structuredOutputRequiresWrapping;
_metadata = metadata;
}

Expand Down Expand Up @@ -485,65 +483,27 @@ schema.ValueKind is not JsonValueKind.Object ||
return descriptionElement.GetString();
}

private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping)
private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For backward compatibility reasons, I don't think we can completely eliminate the wrapping of non-object schemas, since this will still be needed for clients using 2025-11-25 or earlier protocol versions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

wrapping for non-object schemas is restored in c30725c, just at the wire emission boundary instead of inside CreateOutputSchema. The classification + transformation logic is the same as before SEP-2106; it moved from registration-time storage to per-request emission so a single registered Tool can serve both legacy and SEP-2106 clients off the same server

{
structuredOutputRequiresWrapping = false;

if (toolCreateOptions?.UseStructuredContent is not true)
{
return null;
}

// Per SEP-2106, any valid JSON Schema document is acceptable for outputSchema —
// arrays, primitives, compositions, and nullable types pass through unchanged.
// Explicit OutputSchema takes precedence over AIFunction's return schema.
JsonElement outputSchema;
if (toolCreateOptions.OutputSchema is { } explicitSchema)
{
outputSchema = explicitSchema;
}
else if (function.ReturnJsonSchema is { } returnSchema)
{
outputSchema = returnSchema;
}
else
{
return null;
return explicitSchema;
}

if (outputSchema.ValueKind is not JsonValueKind.Object ||
!outputSchema.TryGetProperty("type", out JsonElement typeProperty) ||
typeProperty.ValueKind is not JsonValueKind.String ||
typeProperty.GetString() is not "object")
if (function.ReturnJsonSchema is { } returnSchema)
{
// If the output schema is not an object, need to modify to be a valid MCP output schema.
JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement);

if (schemaNode is JsonObject objSchema &&
objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) &&
typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null"))
{
// For schemas that are of type ["object", "null"], replace with just "object" to be conformant.
objSchema["type"] = "object";
}
else
{
// For anything else, wrap the schema in an envelope with a "result" property.
schemaNode = new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject
{
["result"] = schemaNode
},
["required"] = new JsonArray { (JsonNode)"result" }
};

structuredOutputRequiresWrapping = true;
}

outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
return returnSchema;
}

return outputSchema;
return null;
}

private JsonElement? CreateStructuredResponse(object? aiFunctionResult)
Expand All @@ -554,26 +514,15 @@ typeProperty.ValueKind is not JsonValueKind.String ||
return null;
}

JsonElement? elementResult = aiFunctionResult switch
// Per SEP-2106, the tool's return value flows through to structuredContent unchanged.
// No "result" envelope wrapping — the value's natural JSON shape is what the schema describes.
return aiFunctionResult switch
{
JsonElement jsonElement => jsonElement,
JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode),
null => null,
_ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
};

if (_structuredOutputRequiresWrapping)
{
JsonNode? resultNode = elementResult is { } je
? JsonSerializer.SerializeToNode(je, McpJsonUtilities.JsonContext.Default.JsonElement)
: null;
return JsonSerializer.SerializeToElement(new JsonObject
{
["result"] = resultNode
}, McpJsonUtilities.JsonContext.Default.JsonObject);
}

return elementResult;
}

private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable<AIContent> contentItems, JsonElement? structuredContent)
Expand Down
47 changes: 47 additions & 0 deletions tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,51 @@ public static void ToolInputSchema_AcceptsValidSchemaDocuments(string validSchem

Assert.True(JsonElement.DeepEquals(document.RootElement, tool.InputSchema));
}

[Theory]
[InlineData("null")]
[InlineData("3.5e3")]
[InlineData("[]")]
[InlineData("\"a-string\"")]
public static void ToolOutputSchema_RejectsInvalidJsonSchemaDocuments(string invalidSchema)
{
// Per SEP-2106 / JSON Schema 2020-12 §4.3, a schema document is either a JSON object
// or a boolean (true/false). Other JSON values — null literals, numbers, strings,
// arrays — are not valid schema documents and are rejected.
using var document = JsonDocument.Parse(invalidSchema);
var tool = new Tool { Name = "test" };

Assert.Throws<ArgumentException>(() => tool.OutputSchema = document.RootElement);
}

[Theory]
[InlineData("""{"type":"object"}""")]
[InlineData("""{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}""")]
[InlineData("""{"type":"array","items":{"type":"integer"}}""")]
[InlineData("""{"type":"string"}""")]
[InlineData("""{"type":"number"}""")]
[InlineData("""{"type":"integer","minimum":0}""")]
[InlineData("""{"type":"boolean"}""")]
[InlineData("""{"type":["object","null"],"properties":{"name":{"type":"string"}}}""")]
[InlineData("""{}""")]
[InlineData("""{"oneOf":[{"type":"string"},{"type":"integer"}]}""")]
[InlineData("true")]
[InlineData("false")]
public static void ToolOutputSchema_AcceptsAnyValidJsonSchemaDocument(string validSchema)
{
// Per SEP-2106, OutputSchema accepts any valid JSON Schema 2020-12 document — JSON
// objects (with arrays, primitives, compositions, nullable types) plus the boolean
// schemas `true` (matches any value) and `false` (matches nothing). The `true` form
// also appears organically as the auto-derived schema for an unconstrained `object`
// return type.
using var document = JsonDocument.Parse(validSchema);
Tool tool = new()
{
Name = "test",
OutputSchema = document.RootElement,
};

Assert.NotNull(tool.OutputSchema);
Assert.True(JsonElement.DeepEquals(document.RootElement, tool.OutputSchema.Value));
}
}
51 changes: 36 additions & 15 deletions tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,10 @@ public async Task SupportsSchemaCreateOptions()
[MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))]
public async Task StructuredOutput_Enabled_ReturnsExpectedSchema<T>(T value)
{
// Per SEP-2106 the output schema's top-level "type" matches the natural shape of the
// return value (e.g. "string", "integer", "array") rather than always being "object".
// The strict round-trip check is AssertMatchesJsonSchema below, which proves the
// emitted structuredContent validates against the published schema.
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
McpServerTool tool = McpServerTool.Create(() => value, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options });
var mockServer = new Mock<McpServer>();
Expand All @@ -469,7 +473,6 @@ public async Task StructuredOutput_Enabled_ReturnsExpectedSchema<T>(T value)
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);

Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
Assert.NotNull(result.StructuredContent);
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}
Expand Down Expand Up @@ -594,9 +597,11 @@ public void OutputSchema_Options_RequiresUseStructuredContent()
}

[Fact]
public void OutputSchema_Options_NonObjectSchema_GetsWrapped()
public void OutputSchema_Options_NonObjectSchema_PassesThrough()
{
// Non-object output schema should be wrapped in a "result" property envelope
// Per SEP-2106, outputSchema may be any valid JSON Schema document — including
// non-object schemas. The SDK no longer wraps non-object schemas in a
// {"type":"object","properties":{"result":<schema>}} envelope.
JsonElement outputSchema = JsonDocument.Parse("""{"type":"string"}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => "result", new()
{
Expand All @@ -605,16 +610,15 @@ public void OutputSchema_Options_NonObjectSchema_GetsWrapped()
});

Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("result", out var resultProp));
Assert.Equal("string", resultProp.GetProperty("type").GetString());
Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
Assert.False(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out _));
}

[Fact]
public void OutputSchema_Options_NullableObjectSchema_BecomesObject()
public void OutputSchema_Options_NullableObjectSchema_PassesThrough()
{
// ["object", "null"] type should be simplified to just "object"
// Per SEP-2106, the SDK no longer normalizes ["object","null"] type-arrays down
// to just "object". The schema author's intent is preserved on the wire.
JsonElement outputSchema = JsonDocument.Parse("""{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => "result", new()
{
Expand All @@ -623,7 +627,24 @@ public void OutputSchema_Options_NullableObjectSchema_BecomesObject()
});

Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
var typeProperty = tool.ProtocolTool.OutputSchema.Value.GetProperty("type");
Assert.Equal(JsonValueKind.Array, typeProperty.ValueKind);
Assert.Collection(typeProperty.EnumerateArray(),
t => Assert.Equal("object", t.GetString()),
t => Assert.Equal("null", t.GetString()));
}

[Fact]
public void OutputSchema_Create_StringReturn_NoEnvelope()
{
// End-to-end check: a tool with a string return type and UseStructuredContent
// produces an outputSchema describing the string directly (no "result" envelope)
// and emits the raw string value as structuredContent.
McpServerTool tool = McpServerTool.Create(() => "hello", new() { UseStructuredContent = true });

Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
Assert.False(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out _));
}

[Fact]
Expand Down Expand Up @@ -1004,15 +1025,15 @@ public void ReturnDescription_StructuredOutputDisabled_IncludedInToolDescription
[Fact]
public void ReturnDescription_StructuredOutputEnabled_NotIncludedInToolDescription()
{
// When UseStructuredContent is true, return description should be in the output schema, not in tool description
// When UseStructuredContent is true, return description should be in the output schema, not in tool description.
// Per SEP-2106 the schema is no longer wrapped in a {"result": <schema>} envelope, so the description
// sits directly on the (non-object) output schema.
McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription, new() { UseStructuredContent = true });

Assert.Equal("Tool that returns data.", tool.ProtocolTool.Description);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
// Verify the output schema contains the description
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("result", out var result));
Assert.True(result.TryGetProperty("description", out var description));
Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("description", out var description));
Assert.Equal("The computed result", description.GetString());
}

Expand Down
Loading