Skip to content

Commit fa017c0

Browse files
Add structured output/output schema support for server-side tools. (#480)
* Add structured output/output schema support for server-side tools. * Address feedback. * Add McpServerToolAttribute.UseStructuredContent --------- Co-authored-by: Stephen Toub <[email protected]>
1 parent 46d25b7 commit fa017c0

File tree

10 files changed

+310
-20
lines changed

10 files changed

+310
-20
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,6 @@
7575
<PackageVersion Include="xunit.v3" Version="2.0.2" />
7676
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.0" />
7777
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
78+
<PackageVersion Include="JsonSchema.Net" Version="7.3.4" />
7879
</ItemGroup>
7980
</Project>

ModelContextProtocol.slnx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
<File Path="logo.png" />
2525
<File Path="nuget.config" />
2626
<File Path="README.MD" />
27-
<File Path="version.json" />
2827
</Folder>
2928
<Folder Name="/src/">
3029
<File Path="src/Directory.Build.props" />

src/ModelContextProtocol.Core/McpJsonUtilities.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.AI;
22
using ModelContextProtocol.Protocol;
3+
using ModelContextProtocol.Server;
34
using System.Diagnostics.CodeAnalysis;
45
using System.Text.Json;
56
using System.Text.Json.Serialization;
@@ -75,6 +76,30 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
7576
return false; // No type keyword found.
7677
}
7778

79+
internal static JsonElement? GetReturnSchema(this AIFunction function, AIJsonSchemaCreateOptions? schemaCreateOptions)
80+
{
81+
// TODO replace with https://github.com/dotnet/extensions/pull/6447 once merged.
82+
if (function.UnderlyingMethod?.ReturnType is not Type returnType)
83+
{
84+
return null;
85+
}
86+
87+
if (returnType == typeof(void) || returnType == typeof(Task) || returnType == typeof(ValueTask))
88+
{
89+
// Do not report an output schema for void or Task methods.
90+
return null;
91+
}
92+
93+
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() is Type genericTypeDef &&
94+
(genericTypeDef == typeof(Task<>) || genericTypeDef == typeof(ValueTask<>)))
95+
{
96+
// Extract the real type from Task<T> or ValueTask<T> if applicable.
97+
returnType = returnType.GetGenericArguments()[0];
98+
}
99+
100+
return AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: function.JsonSerializerOptions, inferenceOptions: schemaCreateOptions);
101+
}
102+
78103
// Keep in sync with CreateDefaultOptions above.
79104
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
80105
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,

src/ModelContextProtocol.Core/Protocol/CallToolResponse.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.Json.Nodes;
12
using System.Text.Json.Serialization;
23

34
namespace ModelContextProtocol.Protocol;
@@ -27,6 +28,12 @@ public class CallToolResponse
2728
[JsonPropertyName("content")]
2829
public List<Content> Content { get; set; } = [];
2930

31+
/// <summary>
32+
/// Gets or sets an optional JSON object representing the structured result of the tool call.
33+
/// </summary>
34+
[JsonPropertyName("structuredContent")]
35+
public JsonNode? StructuredContent { get; set; }
36+
3037
/// <summary>
3138
/// Gets or sets an indication of whether the tool call was unsuccessful.
3239
/// </summary>

src/ModelContextProtocol.Core/Protocol/Tool.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ namespace ModelContextProtocol.Protocol;
88
/// </summary>
99
public class Tool
1010
{
11-
private JsonElement _inputSchema = McpJsonUtilities.DefaultMcpToolSchema;
12-
1311
/// <summary>
1412
/// Gets or sets the name of the tool.
1513
/// </summary>
@@ -53,15 +51,44 @@ public class Tool
5351
[JsonPropertyName("inputSchema")]
5452
public JsonElement InputSchema
5553
{
56-
get => _inputSchema;
54+
get => field;
5755
set
5856
{
5957
if (!McpJsonUtilities.IsValidMcpToolSchema(value))
6058
{
61-
throw new ArgumentException("The specified document is not a valid MCP tool JSON schema.", nameof(InputSchema));
59+
throw new ArgumentException("The specified document is not a valid MCP tool input JSON schema.", nameof(InputSchema));
60+
}
61+
62+
field = value;
63+
}
64+
65+
} = McpJsonUtilities.DefaultMcpToolSchema;
66+
67+
/// <summary>
68+
/// Gets or sets a JSON Schema object defining the expected structured outputs for the tool.
69+
/// </summary>
70+
/// <remarks>
71+
/// <para>
72+
/// The schema must be a valid JSON Schema object with the "type" property set to "object".
73+
/// This is enforced by validation in the setter which will throw an <see cref="ArgumentException"/>
74+
/// if an invalid schema is provided.
75+
/// </para>
76+
/// <para>
77+
/// The schema should describe the shape of the data as returned in <see cref="CallToolResponse.StructuredContent"/>.
78+
/// </para>
79+
/// </remarks>
80+
[JsonPropertyName("outputSchema")]
81+
public JsonElement? OutputSchema
82+
{
83+
get => field;
84+
set
85+
{
86+
if (value is not null && !McpJsonUtilities.IsValidMcpToolSchema(value.Value))
87+
{
88+
throw new ArgumentException("The specified document is not a valid MCP tool output JSON schema.", nameof(OutputSchema));
6289
}
6390

64-
_inputSchema = value;
91+
field = value;
6592
}
6693
}
6794

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
using System.Diagnostics.CodeAnalysis;
88
using System.Reflection;
99
using System.Text.Json;
10+
using System.Text.Json.Nodes;
1011

1112
namespace ModelContextProtocol.Server;
1213

1314
/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
1415
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
1516
{
1617
private readonly ILogger _logger;
18+
private readonly bool _structuredOutputRequiresWrapping;
1719

1820
/// <summary>
1921
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
@@ -176,7 +178,8 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
176178
{
177179
Name = options?.Name ?? function.Name,
178180
Description = options?.Description ?? function.Description,
179-
InputSchema = function.JsonSchema,
181+
InputSchema = function.JsonSchema,
182+
OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping),
180183
};
181184

182185
if (options is not null)
@@ -198,7 +201,7 @@ options.OpenWorld is not null ||
198201
}
199202
}
200203

201-
return new AIFunctionMcpServerTool(function, tool, options?.Services);
204+
return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping);
202205
}
203206

204207
private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options)
@@ -229,6 +232,8 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
229232
{
230233
newOptions.ReadOnly ??= readOnly;
231234
}
235+
236+
newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
232237
}
233238

234239
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
@@ -243,11 +248,12 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
243248
internal AIFunction AIFunction { get; }
244249

245250
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
246-
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider)
251+
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping)
247252
{
248253
AIFunction = function;
249254
ProtocolTool = tool;
250255
_logger = serviceProvider?.GetService<ILoggerFactory>()?.CreateLogger<McpServerTool>() ?? (ILogger)NullLogger.Instance;
256+
_structuredOutputRequiresWrapping = structuredOutputRequiresWrapping;
251257
}
252258

253259
/// <inheritdoc />
@@ -295,39 +301,46 @@ public override async ValueTask<CallToolResponse> InvokeAsync(
295301
};
296302
}
297303

304+
JsonNode? structuredContent = CreateStructuredResponse(result);
298305
return result switch
299306
{
300307
AIContent aiContent => new()
301308
{
302309
Content = [aiContent.ToContent()],
310+
StructuredContent = structuredContent,
303311
IsError = aiContent is ErrorContent
304312
},
305313

306314
null => new()
307315
{
308-
Content = []
316+
Content = [],
317+
StructuredContent = structuredContent,
309318
},
310319

311320
string text => new()
312321
{
313-
Content = [new() { Text = text, Type = "text" }]
322+
Content = [new() { Text = text, Type = "text" }],
323+
StructuredContent = structuredContent,
314324
},
315325

316326
Content content => new()
317327
{
318-
Content = [content]
328+
Content = [content],
329+
StructuredContent = structuredContent,
319330
},
320331

321332
IEnumerable<string> texts => new()
322333
{
323-
Content = [.. texts.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })]
334+
Content = [.. texts.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })],
335+
StructuredContent = structuredContent,
324336
},
325337

326-
IEnumerable<AIContent> contentItems => ConvertAIContentEnumerableToCallToolResponse(contentItems),
338+
IEnumerable<AIContent> contentItems => ConvertAIContentEnumerableToCallToolResponse(contentItems, structuredContent),
327339

328340
IEnumerable<Content> contents => new()
329341
{
330-
Content = [.. contents]
342+
Content = [.. contents],
343+
StructuredContent = structuredContent,
331344
},
332345

333346
CallToolResponse callToolResponse => callToolResponse,
@@ -338,12 +351,90 @@ public override async ValueTask<CallToolResponse> InvokeAsync(
338351
{
339352
Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
340353
Type = "text"
341-
}]
354+
}],
355+
StructuredContent = structuredContent,
342356
},
343357
};
344358
}
345359

346-
private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEnumerable<AIContent> contentItems)
360+
private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping)
361+
{
362+
structuredOutputRequiresWrapping = false;
363+
364+
if (toolCreateOptions?.UseStructuredContent is not true)
365+
{
366+
return null;
367+
}
368+
369+
if (function.GetReturnSchema(toolCreateOptions?.SchemaCreateOptions) is not JsonElement outputSchema)
370+
{
371+
return null;
372+
}
373+
374+
if (outputSchema.ValueKind is not JsonValueKind.Object ||
375+
!outputSchema.TryGetProperty("type", out JsonElement typeProperty) ||
376+
typeProperty.ValueKind is not JsonValueKind.String ||
377+
typeProperty.GetString() is not "object")
378+
{
379+
// If the output schema is not an object, need to modify to be a valid MCP output schema.
380+
JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement);
381+
382+
if (schemaNode is JsonObject objSchema &&
383+
objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) &&
384+
typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null"))
385+
{
386+
// For schemas that are of type ["object", "null"], replace with just "object" to be conformant.
387+
objSchema["type"] = "object";
388+
}
389+
else
390+
{
391+
// For anything else, wrap the schema in an envelope with a "result" property.
392+
schemaNode = new JsonObject
393+
{
394+
["type"] = "object",
395+
["properties"] = new JsonObject
396+
{
397+
["result"] = schemaNode
398+
},
399+
["required"] = new JsonArray { (JsonNode)"result" }
400+
};
401+
402+
structuredOutputRequiresWrapping = true;
403+
}
404+
405+
outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
406+
}
407+
408+
return outputSchema;
409+
}
410+
411+
private JsonNode? CreateStructuredResponse(object? aiFunctionResult)
412+
{
413+
if (ProtocolTool.OutputSchema is null)
414+
{
415+
// Only provide structured responses if the tool has an output schema defined.
416+
return null;
417+
}
418+
419+
JsonNode? nodeResult = aiFunctionResult switch
420+
{
421+
JsonNode node => node,
422+
JsonElement jsonElement => JsonSerializer.SerializeToNode(jsonElement, McpJsonUtilities.JsonContext.Default.JsonElement),
423+
_ => JsonSerializer.SerializeToNode(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
424+
};
425+
426+
if (_structuredOutputRequiresWrapping)
427+
{
428+
return new JsonObject
429+
{
430+
["result"] = nodeResult
431+
};
432+
}
433+
434+
return nodeResult;
435+
}
436+
437+
private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEnumerable<AIContent> contentItems, JsonNode? structuredContent)
347438
{
348439
List<Content> contentList = [];
349440
bool allErrorContent = true;
@@ -363,6 +454,7 @@ private static CallToolResponse ConvertAIContentEnumerableToCallToolResponse(IEn
363454
return new()
364455
{
365456
Content = contentList,
457+
StructuredContent = structuredContent,
366458
IsError = allErrorContent && hasAny
367459
};
368460
}

src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,13 @@ public bool ReadOnly
240240
get => _readOnly ?? ReadOnlyDefault;
241241
set => _readOnly = value;
242242
}
243+
244+
/// <summary>
245+
/// Gets or sets whether the tool should report an output schema for structured content.
246+
/// </summary>
247+
/// <remarks>
248+
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
249+
/// and provide structured content in the <see cref="CallToolResponse.StructuredContent"/> property.
250+
/// </remarks>
251+
public bool UseStructuredContent { get; set; }
243252
}

src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.Extensions.AI;
2+
using ModelContextProtocol.Protocol;
23
using System.ComponentModel;
34
using System.Text.Json;
45

@@ -24,7 +25,7 @@ public sealed class McpServerToolCreateOptions
2425
/// Gets or sets optional services used in the construction of the <see cref="McpServerTool"/>.
2526
/// </summary>
2627
/// <remarks>
27-
/// These services will be used to determine which parameters should be satisifed from dependency injection. As such,
28+
/// These services will be used to determine which parameters should be satisfied from dependency injection. As such,
2829
/// what services are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
2930
/// </remarks>
3031
public IServiceProvider? Services { get; set; }
@@ -124,6 +125,15 @@ public sealed class McpServerToolCreateOptions
124125
/// </remarks>
125126
public bool? ReadOnly { get; set; }
126127

128+
/// <summary>
129+
/// Gets or sets whether the tool should report an output schema for structured content.
130+
/// </summary>
131+
/// <remarks>
132+
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
133+
/// and provide structured content in the <see cref="CallToolResponse.StructuredContent"/> property.
134+
/// </remarks>
135+
public bool UseStructuredContent { get; set; }
136+
127137
/// <summary>
128138
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
129139
/// </summary>
@@ -154,6 +164,7 @@ internal McpServerToolCreateOptions Clone() =>
154164
Idempotent = Idempotent,
155165
OpenWorld = OpenWorld,
156166
ReadOnly = ReadOnly,
167+
UseStructuredContent = UseStructuredContent,
157168
SerializerOptions = SerializerOptions,
158169
SchemaCreateOptions = SchemaCreateOptions,
159170
};

0 commit comments

Comments
 (0)