From 58515b43719510c0264cb443bf7c7fdf7fc5510b Mon Sep 17 00:00:00 2001 From: Jayaraman Venkatesan <112980436+jayaraman-venkatesan@users.noreply.github.com> Date: Tue, 5 May 2026 21:28:17 -0400 Subject: [PATCH 1/2] docs(feature/sep-2200-tool-result-visibility): document SEP-2200 content vs. structuredContent semantics and add decoupled-fields sample --- samples/EverythingServer/Program.cs | 1 + .../Tools/WeatherStructuredTool.cs | 48 +++++++++++++++++++ .../Client/McpClient.Methods.cs | 14 ++++++ .../Protocol/CallToolResult.cs | 37 +++++++++++++- .../Server/McpServerToolAttribute.cs | 21 ++++++++ .../Server/McpServerToolCreateOptions.cs | 21 ++++++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 samples/EverythingServer/Tools/WeatherStructuredTool.cs diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index f8c975212..574d9efcc 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -132,6 +132,7 @@ .WithTools() .WithTools() .WithTools() + .WithTools() .WithPrompts() .WithPrompts() .WithResources() diff --git a/samples/EverythingServer/Tools/WeatherStructuredTool.cs b/samples/EverythingServer/Tools/WeatherStructuredTool.cs new file mode 100644 index 000000000..6e965154d --- /dev/null +++ b/samples/EverythingServer/Tools/WeatherStructuredTool.cs @@ -0,0 +1,48 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; + +namespace EverythingServer.Tools; + +// Demonstrates the SEP-2200 ("Clarify tool result content visibility") recommended pattern: +// return a CallToolResult with a model-friendly Content (prose) AND a machine-friendly +// StructuredContent (strict JSON), advertising the JSON schema via OutputSchemaType. +// +// The default behaviour for [McpServerTool(UseStructuredContent = true)] returning a plain +// object is to JSON-stringify the same payload into both fields. SEP-2200 calls that +// "acceptable but may be suboptimal" — a short prose summary in Content saves tokens and +// is easier for the model to reason about, while StructuredContent stays available for +// programmatic consumers (UI, downstream tools, orchestration logic). +[McpServerToolType] +public class WeatherStructuredTool +{ + public sealed record WeatherReading(string City, int TempF, string Condition, int Humidity); + + [McpServerTool( + Name = "getWeather", + UseStructuredContent = true, + OutputSchemaType = typeof(WeatherReading)), + Description("Gets the current weather for a city.")] + public static CallToolResult GetWeather( + [Description("The city to look up the weather for.")] string city) + { + // In a real tool, fetch this from a weather API. + var reading = new WeatherReading(City: city, TempF: 72, Condition: "sunny", Humidity: 40); + + return new CallToolResult + { + // Model-oriented: short, prose-friendly. This is what an LLM reads. + Content = + [ + new TextContentBlock + { + Text = $"It's {reading.TempF}°F and {reading.Condition} in {reading.City} (humidity {reading.Humidity}%).", + }, + ], + // Machine-oriented: strict JSON for UIs, downstream tools, orchestrators. + // Validates against the schema generated from typeof(WeatherReading). + StructuredContent = JsonSerializer.SerializeToElement(reading), + }; + } +} diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 673f66420..314bd9abf 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -859,6 +859,20 @@ public Task UnsubscribeFromResourceAsync( /// The from the tool execution. /// is . /// The request failed or the server returned an error response. + /// + /// + /// The returned may carry both + /// (model-oriented) and (machine-oriented JSON). + /// Per SEP-2200, callers forwarding the result to a language model SHOULD prefer + /// , falling back to + /// only when is empty, and SHOULD NOT pass both fields + /// verbatim to the model. + /// + /// + /// Callers consuming the result programmatically (UI rendering, downstream tools, orchestration + /// logic) should use when present. + /// + /// public ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs index 35dba5b6e..33270f9da 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs @@ -32,14 +32,47 @@ namespace ModelContextProtocol.Protocol; public sealed class CallToolResult : Result { /// - /// Gets or sets the response content from the tool call. + /// Gets or sets the model-oriented response content from the tool call. /// + /// + /// + /// Per the MCP specification (see SEP-2200, "Clarify tool result content visibility"), + /// is the representation intended for consumption by language models. + /// It may be a concise, prose-friendly summary rather than a verbatim dump of + /// . + /// + /// + /// Clients SHOULD prefer when forwarding a tool result to a model, falling + /// back to only when is empty. Clients SHOULD NOT + /// forward both and verbatim to the model — doing so + /// duplicates information and wastes tokens. + /// + /// + /// When both fields are populated, they SHOULD be semantically equivalent. + /// + /// [JsonPropertyName("content")] public IList Content { get; set; } = []; /// - /// Gets or sets an optional JSON object representing the structured result of the tool call. + /// Gets or sets an optional JSON object representing the machine-oriented structured result of the tool call. /// + /// + /// + /// Per the MCP specification (see SEP-2200, "Clarify tool result content visibility"), + /// is the strict JSON representation intended for programmatic consumers + /// (UI code, downstream tools, orchestrators) — not for direct submission to a language model. + /// + /// + /// When is present and a tool advertises an , + /// the value SHOULD validate against that schema. + /// + /// + /// and SHOULD be semantically equivalent when both + /// are populated, but MAY be a summary or prose form of the same data rather than a + /// verbatim JSON dump. + /// + /// [JsonPropertyName("structuredContent")] public JsonElement? StructuredContent { get; set; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index d67bac18c..098963369 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -260,8 +260,23 @@ public bool ReadOnly /// The default is . /// /// + /// /// When enabled, the tool will attempt to populate the /// and provide structured content in the property. + /// + /// + /// When the tool method returns an arbitrary type (not ), the SDK + /// serializes the return value into both (as a single text block + /// containing the JSON) and . Per SEP-2200 this is + /// acceptable but may be suboptimal — model-facing prose is normally a better fit for + /// than a JSON dump. + /// + /// + /// To return distinct model-friendly text and machine-friendly JSON, declare the tool's return type as + /// , set to the type the structured payload + /// should validate against, and populate and + /// separately on the returned value. + /// /// public bool UseStructuredContent { get; set; } @@ -281,6 +296,12 @@ public bool ReadOnly /// schema to clients. /// /// + /// This is the recommended way to follow SEP-2200's guidance of supplying a model-friendly + /// alongside a strict, schema-validated + /// : return a with both + /// fields set explicitly and use to advertise the JSON schema. + /// + /// /// must also be set to for this property to take effect. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 88d718d13..2aa0e3005 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -124,8 +124,23 @@ public sealed class McpServerToolCreateOptions /// The default is . /// /// + /// /// When enabled, the tool will attempt to populate the /// and provide structured content in the property. + /// + /// + /// When the tool method returns an arbitrary type (not ), the SDK + /// serializes the return value into both (as a single text block + /// containing the JSON) and . Per SEP-2200 this is + /// acceptable but may be suboptimal — model-facing prose is normally a better fit for + /// than a JSON dump. + /// + /// + /// To return distinct model-friendly text and machine-friendly JSON, declare the tool's return type as + /// , set to the schema the structured payload + /// should validate against, and populate and + /// separately on the returned value. + /// /// public bool UseStructuredContent { get; set; } @@ -144,6 +159,12 @@ public sealed class McpServerToolCreateOptions /// needs to advertise a meaningful output schema to clients. /// /// + /// This is the recommended way to follow SEP-2200's guidance of supplying a model-friendly + /// alongside a strict, schema-validated + /// : return a with both + /// fields set explicitly and supply the JSON schema here. + /// + /// /// must also be set to for this property to take effect. /// /// From 5b0b2b8b88e01c130a9ed536dccadfb7e77a71a9 Mon Sep 17 00:00:00 2001 From: Jayaraman Venkatesan <112980436+jayaraman-venkatesan@users.noreply.github.com> Date: Tue, 5 May 2026 22:20:51 -0400 Subject: [PATCH 2/2] fix(feature/sep-2200-tool-result-visibility): forward only Content (not full CallToolResult) when StructuredContent is also present --- .../Client/McpClientTool.cs | 7 ++++++- .../Client/McpClientToolTests.cs | 15 ++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index 6a378caa9..6d4dd12c6 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -133,8 +133,13 @@ internal McpClientTool( // back to the AI service as a multi-modal tool response). However, when there is additional information // carried by the CallToolResult outside of its ContentBlocks, just returning AIContent from those ContentBlocks // would lose that information. So, we only do the translation if there is no additional information to preserve. + // + // Per SEP-2200 ("Clarify tool result content visibility"), Content is the model-oriented field and + // StructuredContent is the machine-oriented field. When both are populated, clients SHOULD prefer + // Content for model context and SHOULD NOT forward StructuredContent verbatim to the model. The gate + // therefore deliberately does NOT include `result.StructuredContent is null` — the presence of + // StructuredContent alongside Content is exactly the SEP-2200 recommended shape. if (result.IsError is not true && - result.StructuredContent is null && result.Meta is not { Count: > 0 }) { switch (result.Content.Count) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs index f789d1960..2112efb90 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -421,20 +421,21 @@ public async Task ErrorTool_ReturnsJsonElement() } [Fact] - public async Task StructuredContentTool_ReturnsJsonElement() + public async Task StructuredContentTool_PrefersContentBlocksForModel() { + // SEP-2200 ("Clarify tool result content visibility"): when both Content and + // StructuredContent are populated and there is no protocol-level information + // to preserve (no IsError, no Meta), the AIFunction adapter must forward + // Content (the model-oriented field) to the model — not the full CallToolResult + // with both fields, which would duplicate information for the LLM. await using McpClient client = await CreateMcpClientForServer(); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var tool = tools.Single(t => t.Name == "structured_content_tool"); var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - var jsonElement = Assert.IsType(result); - Assert.True(jsonElement.TryGetProperty("structuredContent", out var structuredContent)); - Assert.True(structuredContent.TryGetProperty("key", out var key)); - Assert.Equal("value", key.GetString()); - Assert.True(jsonElement.TryGetProperty("content", out var content)); - Assert.Equal(JsonValueKind.Array, content.ValueKind); + var textContent = Assert.IsType(result); + Assert.Equal("Regular content", textContent.Text); } [Fact]