Skip to content

Commit b109728

Browse files
Copilotstephentoub
andcommitted
Add tests for CallToolResult<T>, CallToolAsync<T>, and update existing tests
- Replace OutputSchemaType attribute tests with CallToolResult<T> unit tests - Add 8 new unit tests for CallToolResult<T> in McpServerToolTests - Add 6 new integration tests in CallToolResultOfTTests - Refactor CreateOutputSchema to run wrapping logic on explicit OutputSchema too - All 1465 core tests + 267 AspNetCore tests + 57 analyzer tests pass Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent dcfff8e commit b109728

3 files changed

Lines changed: 296 additions & 50 deletions

File tree

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -527,58 +527,71 @@ schema.ValueKind is not JsonValueKind.Object ||
527527
{
528528
structuredOutputRequiresWrapping = false;
529529

530-
// If an explicit OutputSchema is provided, use it directly.
530+
// Determine the raw output schema to use.
531+
JsonElement? rawSchema = null;
532+
531533
if (toolCreateOptions?.OutputSchema is JsonElement explicitSchema)
532534
{
533535
Debug.Assert(toolCreateOptions.UseStructuredContent, "UseStructuredContent should be true when OutputSchema is set.");
534-
return explicitSchema;
536+
rawSchema = explicitSchema;
535537
}
536-
537-
if (toolCreateOptions?.UseStructuredContent is not true)
538+
else if (toolCreateOptions?.UseStructuredContent is not true)
538539
{
539540
return null;
540541
}
542+
else
543+
{
544+
rawSchema = function.ReturnJsonSchema;
545+
}
541546

542-
if (function.ReturnJsonSchema is not JsonElement outputSchema)
547+
if (rawSchema is not JsonElement outputSchema)
543548
{
544549
return null;
545550
}
546551

547-
if (outputSchema.ValueKind is not JsonValueKind.Object ||
548-
!outputSchema.TryGetProperty("type", out JsonElement typeProperty) ||
549-
typeProperty.ValueKind is not JsonValueKind.String ||
550-
typeProperty.GetString() is not "object")
552+
return EnsureObjectSchema(outputSchema, ref structuredOutputRequiresWrapping);
553+
}
554+
555+
/// <summary>
556+
/// Ensures the schema is a valid MCP output schema (type "object"). Wraps non-object schemas in an envelope.
557+
/// </summary>
558+
private static JsonElement EnsureObjectSchema(JsonElement outputSchema, ref bool structuredOutputRequiresWrapping)
559+
{
560+
if (outputSchema.ValueKind is JsonValueKind.Object &&
561+
outputSchema.TryGetProperty("type", out JsonElement typeProperty) &&
562+
typeProperty.ValueKind is JsonValueKind.String &&
563+
typeProperty.GetString() is "object")
551564
{
552-
// If the output schema is not an object, need to modify to be a valid MCP output schema.
553-
JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement);
565+
return outputSchema;
566+
}
554567

555-
if (schemaNode is JsonObject objSchema &&
556-
objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) &&
557-
typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null"))
558-
{
559-
// For schemas that are of type ["object", "null"], replace with just "object" to be conformant.
560-
objSchema["type"] = "object";
561-
}
562-
else
568+
// If the output schema is not an object, need to modify to be a valid MCP output schema.
569+
JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement);
570+
571+
if (schemaNode is JsonObject objSchema &&
572+
objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) &&
573+
typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null"))
574+
{
575+
// For schemas that are of type ["object", "null"], replace with just "object" to be conformant.
576+
objSchema["type"] = "object";
577+
}
578+
else
579+
{
580+
// For anything else, wrap the schema in an envelope with a "result" property.
581+
schemaNode = new JsonObject
563582
{
564-
// For anything else, wrap the schema in an envelope with a "result" property.
565-
schemaNode = new JsonObject
583+
["type"] = "object",
584+
["properties"] = new JsonObject
566585
{
567-
["type"] = "object",
568-
["properties"] = new JsonObject
569-
{
570-
["result"] = schemaNode
571-
},
572-
["required"] = new JsonArray { (JsonNode)"result" }
573-
};
574-
575-
structuredOutputRequiresWrapping = true;
576-
}
586+
["result"] = schemaNode
587+
},
588+
["required"] = new JsonArray { (JsonNode)"result" }
589+
};
577590

578-
outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
591+
structuredOutputRequiresWrapping = true;
579592
}
580593

581-
return outputSchema;
594+
return JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
582595
}
583596

584597
private JsonNode? CreateStructuredResponse(object? aiFunctionResult)
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
using Microsoft.Extensions.AI;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using ModelContextProtocol.Client;
4+
using ModelContextProtocol.Protocol;
5+
using ModelContextProtocol.Server;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization.Metadata;
8+
9+
namespace ModelContextProtocol.Tests.Server;
10+
11+
public class CallToolResultOfTTests : ClientServerTestBase
12+
{
13+
private McpServerPrimitiveCollection<McpServerTool> _toolCollection = [];
14+
15+
public CallToolResultOfTTests(ITestOutputHelper testOutputHelper)
16+
: base(testOutputHelper, startServer: false)
17+
{
18+
}
19+
20+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
21+
{
22+
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
23+
{
24+
options.ToolCollection = _toolCollection;
25+
});
26+
}
27+
28+
[Fact]
29+
public async Task CallToolAsyncOfT_DeserializesStructuredContent()
30+
{
31+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
32+
_toolCollection.Add(McpServerTool.Create(
33+
() => new CallToolResult<PersonData> { Content = new PersonData { Name = "Alice", Age = 30 } },
34+
new() { Name = "get_person", SerializerOptions = serOpts }));
35+
36+
StartServer();
37+
var client = await CreateMcpClientForServer();
38+
39+
var result = await client.CallToolAsync<PersonData>(
40+
"get_person",
41+
options: new() { JsonSerializerOptions = serOpts },
42+
cancellationToken: TestContext.Current.CancellationToken);
43+
44+
Assert.NotNull(result);
45+
Assert.Equal("Alice", result.Name);
46+
Assert.Equal(30, result.Age);
47+
}
48+
49+
[Fact]
50+
public async Task CallToolAsyncOfT_ThrowsOnIsError()
51+
{
52+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
53+
_toolCollection.Add(McpServerTool.Create(
54+
() => new CallToolResult<string> { Content = "something went wrong", IsError = true },
55+
new() { Name = "error_tool", SerializerOptions = serOpts }));
56+
57+
StartServer();
58+
var client = await CreateMcpClientForServer();
59+
60+
var ex = await Assert.ThrowsAsync<McpException>(
61+
() => client.CallToolAsync<string>(
62+
"error_tool",
63+
options: new() { JsonSerializerOptions = serOpts },
64+
cancellationToken: TestContext.Current.CancellationToken).AsTask());
65+
66+
Assert.Contains("something went wrong", ex.Message);
67+
}
68+
69+
[Fact]
70+
public async Task CallToolAsyncOfT_FallsBackToTextContent()
71+
{
72+
// Use a regular tool that returns structured text, not CallToolResult<T>
73+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
74+
_toolCollection.Add(McpServerTool.Create(
75+
() => new PersonData { Name = "Bob", Age = 25 },
76+
new() { Name = "text_tool", UseStructuredContent = true, SerializerOptions = serOpts }));
77+
78+
StartServer();
79+
var client = await CreateMcpClientForServer();
80+
81+
var result = await client.CallToolAsync<PersonData>(
82+
"text_tool",
83+
options: new() { JsonSerializerOptions = serOpts },
84+
cancellationToken: TestContext.Current.CancellationToken);
85+
86+
Assert.NotNull(result);
87+
Assert.Equal("Bob", result.Name);
88+
Assert.Equal(25, result.Age);
89+
}
90+
91+
[Fact]
92+
public async Task CallToolResultOfT_AdvertisesOutputSchema()
93+
{
94+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
95+
_toolCollection.Add(McpServerTool.Create(
96+
() => new CallToolResult<PersonData> { Content = new PersonData { Name = "Alice", Age = 30 } },
97+
new() { Name = "schema_tool", SerializerOptions = serOpts }));
98+
99+
StartServer();
100+
var client = await CreateMcpClientForServer();
101+
102+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
103+
var tool = Assert.Single(tools);
104+
105+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
106+
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
107+
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props));
108+
Assert.True(props.TryGetProperty("Name", out _));
109+
Assert.True(props.TryGetProperty("Age", out _));
110+
}
111+
112+
[Fact]
113+
public async Task CallToolAsyncOfT_WithAsyncTool_Works()
114+
{
115+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
116+
_toolCollection.Add(McpServerTool.Create(
117+
async () =>
118+
{
119+
await Task.CompletedTask;
120+
return new CallToolResult<PersonData> { Content = new PersonData { Name = "Charlie", Age = 35 } };
121+
},
122+
new() { Name = "async_tool", SerializerOptions = serOpts }));
123+
124+
StartServer();
125+
var client = await CreateMcpClientForServer();
126+
127+
var result = await client.CallToolAsync<PersonData>(
128+
"async_tool",
129+
options: new() { JsonSerializerOptions = serOpts },
130+
cancellationToken: TestContext.Current.CancellationToken);
131+
132+
Assert.NotNull(result);
133+
Assert.Equal("Charlie", result.Name);
134+
Assert.Equal(35, result.Age);
135+
}
136+
137+
[Fact]
138+
public async Task CallToolAsyncOfT_WithArguments_Works()
139+
{
140+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
141+
_toolCollection.Add(McpServerTool.Create(
142+
(string name, int age) => new CallToolResult<PersonData>
143+
{
144+
Content = new PersonData { Name = name, Age = age }
145+
},
146+
new() { Name = "create_person", SerializerOptions = serOpts }));
147+
148+
StartServer();
149+
var client = await CreateMcpClientForServer();
150+
151+
var result = await client.CallToolAsync<PersonData>(
152+
"create_person",
153+
new Dictionary<string, object?> { ["name"] = "Diana", ["age"] = 28 },
154+
options: new() { JsonSerializerOptions = serOpts },
155+
cancellationToken: TestContext.Current.CancellationToken);
156+
157+
Assert.NotNull(result);
158+
Assert.Equal("Diana", result.Name);
159+
Assert.Equal(28, result.Age);
160+
}
161+
162+
private class PersonData
163+
{
164+
public string? Name { get; set; }
165+
public int Age { get; set; }
166+
}
167+
}

0 commit comments

Comments
 (0)