Skip to content
Open
Show file tree
Hide file tree
Changes from all 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) =>
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)
{
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