Skip to content

Commit 5d32edd

Browse files
authored
Merge branch 'main' into localden/experimental
2 parents ecf63e3 + fa017c0 commit 5d32edd

12 files changed

Lines changed: 336 additions & 21 deletions

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,6 @@
7878
<PackageVersion Include="xunit.v3" Version="2.0.2" />
7979
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.0" />
8080
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
81+
<PackageVersion Include="JsonSchema.Net" Version="7.3.4" />
8182
</ItemGroup>
8283
</Project>

ModelContextProtocol.slnx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
<File Path="logo.png" />
2727
<File Path="nuget.config" />
2828
<File Path="README.MD" />
29-
<File Path="version.json" />
3029
</Folder>
3130
<Folder Name="/src/">
3231
<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,6 +1,7 @@
11
using Microsoft.Extensions.AI;
22
using ModelContextProtocol.Authentication;
33
using ModelContextProtocol.Protocol;
4+
using ModelContextProtocol.Server;
45
using System.Diagnostics.CodeAnalysis;
56
using System.Text.Json;
67
using System.Text.Json.Serialization;
@@ -76,6 +77,30 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
7677
return false; // No type keyword found.
7778
}
7879

80+
internal static JsonElement? GetReturnSchema(this AIFunction function, AIJsonSchemaCreateOptions? schemaCreateOptions)
81+
{
82+
// TODO replace with https://github.com/dotnet/extensions/pull/6447 once merged.
83+
if (function.UnderlyingMethod?.ReturnType is not Type returnType)
84+
{
85+
return null;
86+
}
87+
88+
if (returnType == typeof(void) || returnType == typeof(Task) || returnType == typeof(ValueTask))
89+
{
90+
// Do not report an output schema for void or Task methods.
91+
return null;
92+
}
93+
94+
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() is Type genericTypeDef &&
95+
(genericTypeDef == typeof(Task<>) || genericTypeDef == typeof(ValueTask<>)))
96+
{
97+
// Extract the real type from Task<T> or ValueTask<T> if applicable.
98+
returnType = returnType.GetGenericArguments()[0];
99+
}
100+
101+
return AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: function.JsonSerializerOptions, inferenceOptions: schemaCreateOptions);
102+
}
103+
79104
// Keep in sync with CreateDefaultOptions above.
80105
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
81106
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>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace ModelContextProtocol.Protocol;
4+
5+
/// <summary>
6+
/// Represents additional context information for completion requests.
7+
/// </summary>
8+
/// <remarks>
9+
/// This context provides information that helps the server generate more relevant
10+
/// completion suggestions, such as previously resolved variables in a template.
11+
/// </remarks>
12+
public class CompleteContext
13+
{
14+
/// <summary>
15+
/// Gets or sets previously-resolved variables in a URI template or prompt.
16+
/// </summary>
17+
[JsonPropertyName("arguments")]
18+
public IDictionary<string, string>? Arguments { get; init; }
19+
}

src/ModelContextProtocol.Core/Protocol/CompleteRequestParams.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,11 @@ public class CompleteRequestParams : RequestParams
3030
/// and the current partial input.
3131
/// </summary>
3232
[JsonPropertyName("argument")]
33-
public required Argument Argument { get; init; }
33+
public required Argument Argument { get; init; }
34+
35+
/// <summary>
36+
/// Gets or sets additional, optional context for completions.
37+
/// </summary>
38+
[JsonPropertyName("context")]
39+
public CompleteContext? Context { get; init; }
3440
}

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
}

0 commit comments

Comments
 (0)