Add McpServer/ClientResource{Template} and friends (#391)

* Add McpServer/ClientResource{Template} and friends

This PR copies the same design used by tools and prompts to add McpServerResource, McpServerResourceAttribute, McpServerResourceTypeAttribute, McpClientResource, and then all of those for ResourceTemplate as well.

Unlike the others, [McpServerResource] is permitted not only on methods but also on properties. This convenience is afforded because such resources aren't parameterized, and thus can be easily modeled as properties, likely auto-props, e.g.
```C#
[McpServerResource]
public static BlobResourceContents MyAmazingData { get; } = Create();
```

Details about resources can be encoded into the attributes or provided via McpServerResource{Template}.Create methods. If not URI is explicitly defined, one is derived from the member name and arguments. For templates, this means manufacturing a URI template that includes variables for all non-DI parameters.

This also fixes a few issues discovered along the way:
- McpClientExtensions are updated to use `ValueTask<T>` instead of `Task<T>` where relevant.
- McpServerPrompt was missing handling for progress notifications; that's been rectified. It was also missing configuring the description on the ProtocolPrompt.
- Renamed IMcpServerPrimitive.Name to Id. For resources, names aren't unique.
- After going back and forth, updated McpServer to not fail for missing handlers and to instead register default handlers. It simplifies the implementation and makes it more consistent.
- Added a bunch of primitive types into the McpJsonUtilities JSON source generator context so that such primitives are usable with tools/prompts/resources without folks being required to add them into their own context for Native AOT. I think we should consider pushing this down to AIJsonUtilities, instead, and that will then flow up to McpJsonUtilities automatically.

* Address feedback
This commit is contained in:
Stephen Toub 2025-05-09 18:21:11 -04:00 • committed by GitHub
commit d86b3277d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 4146 additions and 308 deletions

View file

@ -286,7 +286,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
await using IMcpClient client = await CreateMcpClientForServer();
await Assert.ThrowsAsync<NotSupportedException>(() => client.SendRequestAsync<CallToolRequestParams, CallToolResponse>("Method4", new() { Name = "tool" }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<NotSupportedException>(async () => await client.SendRequestAsync<CallToolRequestParams, CallToolResponse>("Method4", new() { Name = "tool" }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
@ -304,7 +304,7 @@ public class McpClientExtensionsTests : ClientServerTestBase
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
await using IMcpClient client = await CreateMcpClientForServer();
await Assert.ThrowsAsync<NotSupportedException>(() => client.GetPromptAsync("Prompt", new Dictionary<string, object?> { ["i"] = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<NotSupportedException>(async () => await client.GetPromptAsync("Prompt", new Dictionary<string, object?> { ["i"] = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]

View file

@ -0,0 +1,950 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Types;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Tests.Client;
public partial class McpClientResourceTemplateTests : ClientServerTestBase
{
public McpClientResourceTemplateTests(ITestOutputHelper outputHelper) : base(outputHelper)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
mcpServerBuilder.WithReadResourceHandler((request, cancellationToken) =>
new ValueTask<ReadResourceResult>(new ReadResourceResult()
{
Contents = [new TextResourceContents() { Text = request.Params?.Uri ?? string.Empty }]
}));
}
public static IEnumerable<object[]> UriTemplate_InputsProduceExpectedOutputs_MemberData()
{
string[] sources =
[
SpecExamples,
SpecExamplesBySection,
ExtendedTests,
];
foreach (var source in sources)
{
var tests = JsonSerializer.Deserialize(source, JsonContext7.Default.DictionaryStringTestGroup);
Assert.NotNull(tests);
foreach (var testGroup in tests.Values)
{
Dictionary<string, object?> variables = [];
foreach (var entry in testGroup.Variables)
{
variables[entry.Key] = entry.Value.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.String => entry.Value.GetString(),
JsonValueKind.Number => entry.Value.GetDouble(),
JsonValueKind.Array => entry.Value.EnumerateArray().Select(i => i.GetString()).ToArray(),
JsonValueKind.Object => entry.Value.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString()),
_ => throw new Exception($"Invalid test case format: {entry.Value.ValueKind}")
};
}
foreach (var testCase in testGroup.TestCases)
{
string uriTemplate = testCase[0].GetString() ?? throw new Exception("Invalid test case format.");
object expected = testCase[1].ValueKind switch
{
JsonValueKind.String => testCase[1].GetString()!,
JsonValueKind.Array => testCase[1].EnumerateArray().Select(i => i.GetString()).ToArray(),
JsonValueKind.False => false,
_ => throw new Exception("Invalid test case format.")
};
yield return new object[] { variables, uriTemplate, expected };
}
}
}
}
[Theory]
[MemberData(nameof(UriTemplate_InputsProduceExpectedOutputs_MemberData))]
public async Task UriTemplate_InputsProduceExpectedOutputs(
IReadOnlyDictionary<string, object?> variables, string uriTemplate, object expected)
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.ReadResourceAsync(uriTemplate, variables, TestContext.Current.CancellationToken);
Assert.NotNull(result);
var actualUri = Assert.IsType<TextResourceContents>(Assert.Single(result.Contents)).Text;
if (expected is string expectedUri)
{
Assert.Equal(expectedUri, actualUri);
}
else
{
Assert.Contains(actualUri, Assert.IsType<string[]>(expected));
}
}
public class TestGroup
{
[JsonPropertyName("level")]
public int Level { get; set; } = 4;
[JsonPropertyName("variables")]
public Dictionary<string, JsonElement> Variables { get; set; } = [];
[JsonPropertyName("testcases")]
public List<List<JsonElement>> TestCases { get; set; } = [];
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)]
[JsonSerializable(typeof(Dictionary<string, TestGroup>))]
internal partial class JsonContext7 : JsonSerializerContext;
// The following data comes from:
// https://github.com/uri-templates/uritemplate-test/tree/1eb27ab4462b9e5819dc47db99044f5fd1fa9bc7
// The JSON from the test case files has been extracted below.
// Copyright 2011- The Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
private const string SpecExamples = """
{
"Level 1 Examples" :
{
"level": 1,
"variables": {
"var" : "value",
"hello" : "Hello World!"
},
"testcases" : [
["{var}", "value"],
["'{var}'", "'value'"],
["{hello}", "Hello%20World%21"]
]
},
"Level 2 Examples" :
{
"level": 2,
"variables": {
"var" : "value",
"hello" : "Hello World!",
"path" : "/foo/bar"
},
"testcases" : [
["{+var}", "value"],
["{+hello}", "Hello%20World!"],
["{+path}/here", "/foo/bar/here"],
["here?ref={+path}", "here?ref=/foo/bar"]
]
},
"Level 3 Examples" :
{
"level": 3,
"variables": {
"var" : "value",
"hello" : "Hello World!",
"empty" : "",
"path" : "/foo/bar",
"x" : "1024",
"y" : "768"
},
"testcases" : [
["map?{x,y}", "map?1024,768"],
["{x,hello,y}", "1024,Hello%20World%21,768"],
["{+x,hello,y}", "1024,Hello%20World!,768"],
["{+path,x}/here", "/foo/bar,1024/here"],
["{#x,hello,y}", "#1024,Hello%20World!,768"],
["{#path,x}/here", "#/foo/bar,1024/here"],
["X{.var}", "X.value"],
["X{.x,y}", "X.1024.768"],
["{/var}", "/value"],
["{/var,x}/here", "/value/1024/here"],
["{;x,y}", ";x=1024;y=768"],
["{;x,y,empty}", ";x=1024;y=768;empty"],
["{?x,y}", "?x=1024&y=768"],
["{?x,y,empty}", "?x=1024&y=768&empty="],
["?fixed=yes{&x}", "?fixed=yes&x=1024"],
["{&x,y,empty}", "&x=1024&y=768&empty="]
]
},
"Level 4 Examples" :
{
"level": 4,
"variables": {
"var": "value",
"hello": "Hello World!",
"path": "/foo/bar",
"list": ["red", "green", "blue"],
"keys": {"semi": ";", "dot": ".", "comma":","}
},
"testcases": [
["{var:3}", "val"],
["{var:30}", "value"],
["{list}", "red,green,blue"],
["{list*}", "red,green,blue"],
["{keys}", [
"comma,%2C,dot,.,semi,%3B",
"comma,%2C,semi,%3B,dot,.",
"dot,.,comma,%2C,semi,%3B",
"dot,.,semi,%3B,comma,%2C",
"semi,%3B,comma,%2C,dot,.",
"semi,%3B,dot,.,comma,%2C"
]],
["{keys*}", [
"comma=%2C,dot=.,semi=%3B",
"comma=%2C,semi=%3B,dot=.",
"dot=.,comma=%2C,semi=%3B",
"dot=.,semi=%3B,comma=%2C",
"semi=%3B,comma=%2C,dot=.",
"semi=%3B,dot=.,comma=%2C"
]],
["{+path:6}/here", "/foo/b/here"],
["{+list}", "red,green,blue"],
["{+list*}", "red,green,blue"],
["{+keys}", [
"comma,,,dot,.,semi,;",
"comma,,,semi,;,dot,.",
"dot,.,comma,,,semi,;",
"dot,.,semi,;,comma,,",
"semi,;,comma,,,dot,.",
"semi,;,dot,.,comma,,"
]],
["{+keys*}", [
"comma=,,dot=.,semi=;",
"comma=,,semi=;,dot=.",
"dot=.,comma=,,semi=;",
"dot=.,semi=;,comma=,",
"semi=;,comma=,,dot=.",
"semi=;,dot=.,comma=,"
]],
["{#path:6}/here", "#/foo/b/here"],
["{#list}", "#red,green,blue"],
["{#list*}", "#red,green,blue"],
["{#keys}", [
"#comma,,,dot,.,semi,;",
"#comma,,,semi,;,dot,.",
"#dot,.,comma,,,semi,;",
"#dot,.,semi,;,comma,,",
"#semi,;,comma,,,dot,.",
"#semi,;,dot,.,comma,,"
]],
["{#keys*}", [
"#comma=,,dot=.,semi=;",
"#comma=,,semi=;,dot=.",
"#dot=.,comma=,,semi=;",
"#dot=.,semi=;,comma=,",
"#semi=;,comma=,,dot=.",
"#semi=;,dot=.,comma=,"
]],
["X{.var:3}", "X.val"],
["X{.list}", "X.red,green,blue"],
["X{.list*}", "X.red.green.blue"],
["X{.keys}", [
"X.comma,%2C,dot,.,semi,%3B",
"X.comma,%2C,semi,%3B,dot,.",
"X.dot,.,comma,%2C,semi,%3B",
"X.dot,.,semi,%3B,comma,%2C",
"X.semi,%3B,comma,%2C,dot,.",
"X.semi,%3B,dot,.,comma,%2C"
]],
["{/var:1,var}", "/v/value"],
["{/list}", "/red,green,blue"],
["{/list*}", "/red/green/blue"],
["{/list*,path:4}", "/red/green/blue/%2Ffoo"],
["{/keys}", [
"/comma,%2C,dot,.,semi,%3B",
"/comma,%2C,semi,%3B,dot,.",
"/dot,.,comma,%2C,semi,%3B",
"/dot,.,semi,%3B,comma,%2C",
"/semi,%3B,comma,%2C,dot,.",
"/semi,%3B,dot,.,comma,%2C"
]],
["{/keys*}", [
"/comma=%2C/dot=./semi=%3B",
"/comma=%2C/semi=%3B/dot=.",
"/dot=./comma=%2C/semi=%3B",
"/dot=./semi=%3B/comma=%2C",
"/semi=%3B/comma=%2C/dot=.",
"/semi=%3B/dot=./comma=%2C"
]],
["{;hello:5}", ";hello=Hello"],
["{;list}", ";list=red,green,blue"],
["{;list*}", ";list=red;list=green;list=blue"],
["{;keys}", [
";keys=comma,%2C,dot,.,semi,%3B",
";keys=comma,%2C,semi,%3B,dot,.",
";keys=dot,.,comma,%2C,semi,%3B",
";keys=dot,.,semi,%3B,comma,%2C",
";keys=semi,%3B,comma,%2C,dot,.",
";keys=semi,%3B,dot,.,comma,%2C"
]],
["{;keys*}", [
";comma=%2C;dot=.;semi=%3B",
";comma=%2C;semi=%3B;dot=.",
";dot=.;comma=%2C;semi=%3B",
";dot=.;semi=%3B;comma=%2C",
";semi=%3B;comma=%2C;dot=.",
";semi=%3B;dot=.;comma=%2C"
]],
["{?var:3}", "?var=val"],
["{?list}", "?list=red,green,blue"],
["{?list*}", "?list=red&list=green&list=blue"],
["{?keys}", [
"?keys=comma,%2C,dot,.,semi,%3B",
"?keys=comma,%2C,semi,%3B,dot,.",
"?keys=dot,.,comma,%2C,semi,%3B",
"?keys=dot,.,semi,%3B,comma,%2C",
"?keys=semi,%3B,comma,%2C,dot,.",
"?keys=semi,%3B,dot,.,comma,%2C"
]],
["{?keys*}", [
"?comma=%2C&dot=.&semi=%3B",
"?comma=%2C&semi=%3B&dot=.",
"?dot=.&comma=%2C&semi=%3B",
"?dot=.&semi=%3B&comma=%2C",
"?semi=%3B&comma=%2C&dot=.",
"?semi=%3B&dot=.&comma=%2C"
]],
["{&var:3}", "&var=val"],
["{&list}", "&list=red,green,blue"],
["{&list*}", "&list=red&list=green&list=blue"],
["{&keys}", [
"&keys=comma,%2C,dot,.,semi,%3B",
"&keys=comma,%2C,semi,%3B,dot,.",
"&keys=dot,.,comma,%2C,semi,%3B",
"&keys=dot,.,semi,%3B,comma,%2C",
"&keys=semi,%3B,comma,%2C,dot,.",
"&keys=semi,%3B,dot,.,comma,%2C"
]],
["{&keys*}", [
"&comma=%2C&dot=.&semi=%3B",
"&comma=%2C&semi=%3B&dot=.",
"&dot=.&comma=%2C&semi=%3B",
"&dot=.&semi=%3B&comma=%2C",
"&semi=%3B&comma=%2C&dot=.",
"&semi=%3B&dot=.&comma=%2C"
]]
]
}
}
""";
private const string SpecExamplesBySection = """
{
"2.1 Literals" :
{
"variables": {
"count" : ["one", "two", "three"]
},
"testcases" : [
["'{count}'", "'one,two,three'"]
]
},
"3.2.1 Variable Expansion" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{count}", "one,two,three"],
["{count*}", "one,two,three"],
["{/count}", "/one,two,three"],
["{/count*}", "/one/two/three"],
["{;count}", ";count=one,two,three"],
["{;count*}", ";count=one;count=two;count=three"],
["{?count}", "?count=one,two,three"],
["{?count*}", "?count=one&count=two&count=three"],
["{&count*}", "&count=one&count=two&count=three"]
]
},
"3.2.2 Simple String Expansion" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{var}", "value"],
["{hello}", "Hello%20World%21"],
["{half}", "50%25"],
["O{empty}X", "OX"],
["O{undef}X", "OX"],
["{x,y}", "1024,768"],
["{x,hello,y}", "1024,Hello%20World%21,768"],
["?{x,empty}", "?1024,"],
["?{x,undef}", "?1024"],
["?{undef,y}", "?768"],
["{var:3}", "val"],
["{var:30}", "value"],
["{list}", "red,green,blue"],
["{list*}", "red,green,blue"],
["{keys}", [
"comma,%2C,dot,.,semi,%3B",
"comma,%2C,semi,%3B,dot,.",
"dot,.,comma,%2C,semi,%3B",
"dot,.,semi,%3B,comma,%2C",
"semi,%3B,comma,%2C,dot,.",
"semi,%3B,dot,.,comma,%2C"
]],
["{keys*}", [
"comma=%2C,dot=.,semi=%3B",
"comma=%2C,semi=%3B,dot=.",
"dot=.,comma=%2C,semi=%3B",
"dot=.,semi=%3B,comma=%2C",
"semi=%3B,comma=%2C,dot=.",
"semi=%3B,dot=.,comma=%2C"
]]
]
},
"3.2.3 Reserved Expansion" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{+var}", "value"],
["{/var,empty}", "/value/"],
["{/var,undef}", "/value"],
["{+hello}", "Hello%20World!"],
["{+half}", "50%25"],
["{base}index", "http%3A%2F%2Fexample.com%2Fhome%2Findex"],
["{+base}index", "http://example.com/home/index"],
["O{+empty}X", "OX"],
["O{+undef}X", "OX"],
["{+path}/here", "/foo/bar/here"],
["{+path:6}/here", "/foo/b/here"],
["here?ref={+path}", "here?ref=/foo/bar"],
["up{+path}{var}/here", "up/foo/barvalue/here"],
["{+x,hello,y}", "1024,Hello%20World!,768"],
["{+path,x}/here", "/foo/bar,1024/here"],
["{+list}", "red,green,blue"],
["{+list*}", "red,green,blue"],
["{+keys}", [
"comma,,,dot,.,semi,;",
"comma,,,semi,;,dot,.",
"dot,.,comma,,,semi,;",
"dot,.,semi,;,comma,,",
"semi,;,comma,,,dot,.",
"semi,;,dot,.,comma,,"
]],
["{+keys*}", [
"comma=,,dot=.,semi=;",
"comma=,,semi=;,dot=.",
"dot=.,comma=,,semi=;",
"dot=.,semi=;,comma=,",
"semi=;,comma=,,dot=.",
"semi=;,dot=.,comma=,"
]]
]
},
"3.2.4 Fragment Expansion" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{#var}", "#value"],
["{#hello}", "#Hello%20World!"],
["{#half}", "#50%25"],
["foo{#empty}", "foo#"],
["foo{#undef}", "foo"],
["{#x,hello,y}", "#1024,Hello%20World!,768"],
["{#path,x}/here", "#/foo/bar,1024/here"],
["{#path:6}/here", "#/foo/b/here"],
["{#list}", "#red,green,blue"],
["{#list*}", "#red,green,blue"],
["{#keys}", [
"#comma,,,dot,.,semi,;",
"#comma,,,semi,;,dot,.",
"#dot,.,comma,,,semi,;",
"#dot,.,semi,;,comma,,",
"#semi,;,comma,,,dot,.",
"#semi,;,dot,.,comma,,"
]]
]
},
"3.2.5 Label Expansion with Dot-Prefix" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{.who}", ".fred"],
["{.who,who}", ".fred.fred"],
["{.half,who}", ".50%25.fred"],
["www{.dom*}", "www.example.com"],
["X{.var}", "X.value"],
["X{.var:3}", "X.val"],
["X{.empty}", "X."],
["X{.undef}", "X"],
["X{.list}", "X.red,green,blue"],
["X{.list*}", "X.red.green.blue"],
["{#keys}", [
"#comma,,,dot,.,semi,;",
"#comma,,,semi,;,dot,.",
"#dot,.,comma,,,semi,;",
"#dot,.,semi,;,comma,,",
"#semi,;,comma,,,dot,.",
"#semi,;,dot,.,comma,,"
]],
["{#keys*}", [
"#comma=,,dot=.,semi=;",
"#comma=,,semi=;,dot=.",
"#dot=.,comma=,,semi=;",
"#dot=.,semi=;,comma=,",
"#semi=;,comma=,,dot=.",
"#semi=;,dot=.,comma=,"
]],
["X{.empty_keys}", "X"],
["X{.empty_keys*}", "X"]
]
},
"3.2.6 Path Segment Expansion" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{/who}", "/fred"],
["{/who,who}", "/fred/fred"],
["{/half,who}", "/50%25/fred"],
["{/who,dub}", "/fred/me%2Ftoo"],
["{/var}", "/value"],
["{/var,empty}", "/value/"],
["{/var,undef}", "/value"],
["{/var,x}/here", "/value/1024/here"],
["{/var:1,var}", "/v/value"],
["{/list}", "/red,green,blue"],
["{/list*}", "/red/green/blue"],
["{/list*,path:4}", "/red/green/blue/%2Ffoo"],
["{/keys}", [
"/comma,%2C,dot,.,semi,%3B",
"/comma,%2C,semi,%3B,dot,.",
"/dot,.,comma,%2C,semi,%3B",
"/dot,.,semi,%3B,comma,%2C",
"/semi,%3B,comma,%2C,dot,.",
"/semi,%3B,dot,.,comma,%2C"
]],
["{/keys*}", [
"/comma=%2C/dot=./semi=%3B",
"/comma=%2C/semi=%3B/dot=.",
"/dot=./comma=%2C/semi=%3B",
"/dot=./semi=%3B/comma=%2C",
"/semi=%3B/comma=%2C/dot=.",
"/semi=%3B/dot=./comma=%2C"
]]
]
},
"3.2.7 Path-Style Parameter Expansion" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{;who}", ";who=fred"],
["{;half}", ";half=50%25"],
["{;empty}", ";empty"],
["{;hello:5}", ";hello=Hello"],
["{;v,empty,who}", ";v=6;empty;who=fred"],
["{;v,bar,who}", ";v=6;who=fred"],
["{;x,y}", ";x=1024;y=768"],
["{;x,y,empty}", ";x=1024;y=768;empty"],
["{;x,y,undef}", ";x=1024;y=768"],
["{;list}", ";list=red,green,blue"],
["{;list*}", ";list=red;list=green;list=blue"],
["{;keys}", [
";keys=comma,%2C,dot,.,semi,%3B",
";keys=comma,%2C,semi,%3B,dot,.",
";keys=dot,.,comma,%2C,semi,%3B",
";keys=dot,.,semi,%3B,comma,%2C",
";keys=semi,%3B,comma,%2C,dot,.",
";keys=semi,%3B,dot,.,comma,%2C"
]],
["{;keys*}", [
";comma=%2C;dot=.;semi=%3B",
";comma=%2C;semi=%3B;dot=.",
";dot=.;comma=%2C;semi=%3B",
";dot=.;semi=%3B;comma=%2C",
";semi=%3B;comma=%2C;dot=.",
";semi=%3B;dot=.;comma=%2C"
]]
]
},
"3.2.8 Form-Style Query Expansion" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{?who}", "?who=fred"],
["{?half}", "?half=50%25"],
["{?x,y}", "?x=1024&y=768"],
["{?x,y,empty}", "?x=1024&y=768&empty="],
["{?x,y,undef}", "?x=1024&y=768"],
["{?var:3}", "?var=val"],
["{?list}", "?list=red,green,blue"],
["{?list*}", "?list=red&list=green&list=blue"],
["{?keys}", [
"?keys=comma,%2C,dot,.,semi,%3B",
"?keys=comma,%2C,semi,%3B,dot,.",
"?keys=dot,.,comma,%2C,semi,%3B",
"?keys=dot,.,semi,%3B,comma,%2C",
"?keys=semi,%3B,comma,%2C,dot,.",
"?keys=semi,%3B,dot,.,comma,%2C"
]],
["{?keys*}", [
"?comma=%2C&dot=.&semi=%3B",
"?comma=%2C&semi=%3B&dot=.",
"?dot=.&comma=%2C&semi=%3B",
"?dot=.&semi=%3B&comma=%2C",
"?semi=%3B&comma=%2C&dot=.",
"?semi=%3B&dot=.&comma=%2C"
]]
]
},
"3.2.9 Form-Style Query Continuation" :
{
"variables": {
"count" : ["one", "two", "three"],
"dom" : ["example", "com"],
"dub" : "me/too",
"hello" : "Hello World!",
"half" : "50%",
"var" : "value",
"who" : "fred",
"base" : "http://example.com/home/",
"path" : "/foo/bar",
"list" : ["red", "green", "blue"],
"keys" : { "semi" : ";", "dot" : ".", "comma" : ","},
"v" : "6",
"x" : "1024",
"y" : "768",
"empty" : "",
"empty_keys" : {},
"undef" : null
},
"testcases" : [
["{&who}", "&who=fred"],
["{&half}", "&half=50%25"],
["?fixed=yes{&x}", "?fixed=yes&x=1024"],
["{&var:3}", "&var=val"],
["{&x,y,empty}", "&x=1024&y=768&empty="],
["{&x,y,undef}", "&x=1024&y=768"],
["{&list}", "&list=red,green,blue"],
["{&list*}", "&list=red&list=green&list=blue"],
["{&keys}", [
"&keys=comma,%2C,dot,.,semi,%3B",
"&keys=comma,%2C,semi,%3B,dot,.",
"&keys=dot,.,comma,%2C,semi,%3B",
"&keys=dot,.,semi,%3B,comma,%2C",
"&keys=semi,%3B,comma,%2C,dot,.",
"&keys=semi,%3B,dot,.,comma,%2C"
]],
["{&keys*}", [
"&comma=%2C&dot=.&semi=%3B",
"&comma=%2C&semi=%3B&dot=.",
"&dot=.&comma=%2C&semi=%3B",
"&dot=.&semi=%3B&comma=%2C",
"&semi=%3B&comma=%2C&dot=.",
"&semi=%3B&dot=.&comma=%2C"
]]
]
}
}
""";
private const string ExtendedTests = """
{
"Additional Examples 1":{
"level":4,
"variables":{
"id" : "person",
"token" : "12345",
"fields" : ["id", "name", "picture"],
"format" : "json",
"q" : "URI Templates",
"page" : "5",
"lang" : "en",
"geocode" : ["37.76","-122.427"],
"first_name" : "John",
"last.name" : "Doe",
"Some%20Thing" : "foo",
"number" : 6,
"long" : 37.76,
"lat" : -122.427,
"group_id" : "12345",
"query" : "PREFIX dc: <http://purl.org/dc/elements/1.1/> SELECT ?book ?who WHERE { ?book dc:creator ?who }",
"uri" : "http://example.org/?uri=http%3A%2F%2Fexample.org%2F",
"word" : "drücken",
"Stra%C3%9Fe" : "Grüner Weg",
"random" : "šö䟜ñꀣ¥‡ÑÒÓÔÕÖרÙÚàáâãäåæçÿ",
"assoc_special_chars" :
{ "šö䟜ñꀣ¥‡ÑÒÓÔÕ" : "ÖרÙÚàáâãäåæçÿ" }
},
"testcases":[
[ "{/id*}" , "/person" ],
[ "{/id*}{?fields,first_name,last.name,token}","/person?fields=id,name,picture&first_name=John&last.name=Doe&token=12345"],
["/search.{format}{?q,geocode,lang,locale,page,result_type}","/search.json?q=URI%20Templates&geocode=37.76,-122.427&lang=en&page=5"],
["/test{/Some%20Thing}", "/test/foo" ],
["/set{?number}", "/set?number=6"],
["/loc{?long,lat}" , "/loc?long=37.76&lat=-122.427"],
["/base{/group_id,first_name}/pages{/page,lang}{?format,q}","/base/12345/John/pages/5/en?format=json&q=URI%20Templates"],
["/sparql{?query}", "/sparql?query=PREFIX%20dc%3A%20%3Chttp%3A%2F%2Fpurl.org%2Fdc%2Felements%2F1.1%2F%3E%20SELECT%20%3Fbook%20%3Fwho%20WHERE%20%7B%20%3Fbook%20dc%3Acreator%20%3Fwho%20%7D"],
["/go{?uri}", "/go?uri=http%3A%2F%2Fexample.org%2F%3Furi%3Dhttp%253A%252F%252Fexample.org%252F"],
["/service{?word}", "/service?word=dr%C3%BCcken"],
["/lookup{?Stra%C3%9Fe}", "/lookup?Stra%C3%9Fe=Gr%C3%BCner%20Weg"],
["{random}" , "%C5%A1%C3%B6%C3%A4%C5%B8%C5%93%C3%B1%C3%AA%E2%82%AC%C2%A3%C2%A5%E2%80%A1%C3%91%C3%92%C3%93%C3%94%C3%95%C3%96%C3%97%C3%98%C3%99%C3%9A%C3%A0%C3%A1%C3%A2%C3%A3%C3%A4%C3%A5%C3%A6%C3%A7%C3%BF"],
["{?assoc_special_chars*}", "?%C5%A1%C3%B6%C3%A4%C5%B8%C5%93%C3%B1%C3%AA%E2%82%AC%C2%A3%C2%A5%E2%80%A1%C3%91%C3%92%C3%93%C3%94%C3%95=%C3%96%C3%97%C3%98%C3%99%C3%9A%C3%A0%C3%A1%C3%A2%C3%A3%C3%A4%C3%A5%C3%A6%C3%A7%C3%BF"]
]
},
"Additional Examples 2":{
"level":4,
"variables":{
"id" : ["person","albums"],
"token" : "12345",
"fields" : ["id", "name", "picture"],
"format" : "atom",
"q" : "URI Templates",
"page" : "10",
"start" : "5",
"lang" : "en",
"geocode" : ["37.76","-122.427"]
},
"testcases":[
[ "{/id*}" , "/person/albums" ],
[ "{/id*}{?fields,token}" , "/person/albums?fields=id,name,picture&token=12345" ]
]
},
"Additional Examples 3: Empty Variables":{
"variables" : {
"empty_list" : [],
"empty_assoc" : {}
},
"testcases":[
[ "{/empty_list}", [ "" ] ],
[ "{/empty_list*}", [ "" ] ],
[ "{?empty_list}", [ ""] ],
[ "{?empty_list*}", [ "" ] ],
[ "{?empty_assoc}", [ "" ] ],
[ "{?empty_assoc*}", [ "" ] ]
]
},
"Additional Examples 4: Numeric Keys":{
"variables" : {
"42" : "The Answer to the Ultimate Question of Life, the Universe, and Everything",
"1337" : ["leet", "as","it", "can","be"],
"german" : {
"11": "elf",
"12": "zwölf"
}
},
"testcases":[
[ "{42}", "The%20Answer%20to%20the%20Ultimate%20Question%20of%20Life%2C%20the%20Universe%2C%20and%20Everything"],
[ "{?42}", "?42=The%20Answer%20to%20the%20Ultimate%20Question%20of%20Life%2C%20the%20Universe%2C%20and%20Everything"],
[ "{1337}", "leet,as,it,can,be"],
[ "{?1337*}", "?1337=leet&1337=as&1337=it&1337=can&1337=be"],
[ "{?german*}", [ "?11=elf&12=zw%C3%B6lf", "?12=zw%C3%B6lf&11=elf"] ]
]
},
"Additional Examples 5: Explode Combinations":{
"variables" : {
"id" : "admin",
"token" : "12345",
"tab" : "overview",
"keys" : {
"key1": "val1",
"key2": "val2"
}
},
"testcases":[
[ "{?id,token,keys*}", [
"?id=admin&token=12345&key1=val1&key2=val2",
"?id=admin&token=12345&key2=val2&key1=val1"]
],
[ "{/id}{?token,keys*}", [
"/admin?token=12345&key1=val1&key2=val2",
"/admin?token=12345&key2=val2&key1=val1"]
],
[ "{?id,token}{&keys*}", [
"?id=admin&token=12345&key1=val1&key2=val2",
"?id=admin&token=12345&key2=val2&key1=val1"]
],
[ "/user{/id}{?token,tab}{&keys*}", [
"/user/admin?token=12345&tab=overview&key1=val1&key2=val2",
"/user/admin?token=12345&tab=overview&key2=val2&key1=val1"]
]
]
},
"Additional Examples 6: Reserved Expansion":{
"variables" : {
"id" : "admin%2F",
"not_pct" : "%foo",
"list" : ["red%25", "%2Fgreen", "blue "],
"keys" : {
"key1": "val1%2F",
"key2": "val2%2F"
}
},
"testcases": [
["{+id}", "admin%2F"],
["{#id}", "#admin%2F"],
["{id}", "admin%252F"],
["{+not_pct}", "%25foo"],
["{#not_pct}", "#%25foo"],
["{not_pct}", "%25foo"],
["{+list}", "red%25,%2Fgreen,blue%20"],
["{#list}", "#red%25,%2Fgreen,blue%20"],
["{list}", "red%2525,%252Fgreen,blue%20"],
["{+keys}", "key1,val1%2F,key2,val2%2F"],
["{#keys}", "#key1,val1%2F,key2,val2%2F"],
["{keys}", "key1,val1%252F,key2,val2%252F"]
]
}
}
""";
}

View file

@ -174,8 +174,8 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIn
// act
await using var client = await _fixture.CreateClientAsync(clientId);
await Assert.ThrowsAsync<McpException>(() =>
client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<McpException>(async () =>
await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken));
}
[Theory]
@ -187,7 +187,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIn
// act
await using var client = await _fixture.CreateClientAsync(clientId);
IList<ResourceTemplate> allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken);
IList<McpClientResourceTemplate> allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken);
// The server provides a single test resource template
Assert.Single(allResourceTemplates);
@ -202,7 +202,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIn
// act
await using var client = await _fixture.CreateClientAsync(clientId);
IList<Resource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
IList<McpClientResource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
// The server provides 100 test resources
Assert.Equal(100, allResources.Count);

View file

@ -664,7 +664,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
},
cancellationToken: TestContext.Current.CancellationToken);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => invokeTask);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await invokeTask);
}
[McpServerToolType]

View file

@ -87,7 +87,7 @@ public class DiagnosticTests
await RunConnected(async (client, server) =>
{
await client.CallToolAsync("Throw", cancellationToken: TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<McpException>(() => client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<McpException>(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken));
}, new List<string>());
}

View file

@ -42,7 +42,7 @@ public class CancellationTests : ClientServerTestBase
return default;
}))
{
await Assert.ThrowsAsync<OperationCanceledException>(() => client.ListToolsAsync(cancellationToken: new CancellationToken(true)));
await Assert.ThrowsAsync<OperationCanceledException>(async () => await client.ListToolsAsync(cancellationToken: new CancellationToken(true)));
}
Assert.False(gotCancellation);

View file

@ -5,7 +5,6 @@ using ModelContextProtocol.Server;
using Moq;
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Server;

View file

@ -1,11 +1,51 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using Moq;
using System.Reflection;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Tests.Server;
public class McpServerResourceTests
public partial class McpServerResourceTests
{
[Fact]
public void CanCreateServerWithResource()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithStdioServerTransport()
.WithListResourcesHandler(async (ctx, ct) =>
{
return new ListResourcesResult
{
Resources =
[
new Resource { Name = "Static Resource", Description = "A static resource with a numeric ID", Uri = "test://static/resource" }
]
};
})
.WithReadResourceHandler(async (ctx, ct) =>
{
return new ReadResourceResult
{
Contents = [new TextResourceContents
{
Uri = ctx.Params!.Uri!,
Text = "Static Resource",
MimeType = "text/plain",
}]
};
});
var provider = services.BuildServiceProvider();
provider.GetRequiredService<IMcpServer>();
}
[Fact]
public void CanCreateServerWithResourceTemplates()
{
@ -42,42 +82,7 @@ public class McpServerResourceTests
}
[Fact]
public void CanCreateServerWithResources()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithStdioServerTransport()
.WithListResourcesHandler(async (ctx, ct) =>
{
return new ListResourcesResult
{
Resources =
[
new Resource { Name = "Static Resource", Description = "A static resource with a numeric ID", Uri = "test://static/resource/foo.txt" }
]
};
})
.WithReadResourceHandler(async (ctx, ct) =>
{
return new ReadResourceResult
{
Contents = [new TextResourceContents
{
Uri = ctx.Params!.Uri!,
Text = "Static Resource",
MimeType = "text/plain",
}]
};
});
var provider = services.BuildServiceProvider();
provider.GetRequiredService<IMcpServer>();
}
[Fact]
public void CreatingReadHandlerWithNoListHandlerFails()
public void CreatingReadHandlerWithNoListHandlerSucceeds()
{
var services = new ServiceCollection();
services.AddMcpServer()
@ -95,6 +100,480 @@ public class McpServerResourceTests
};
});
var sp = services.BuildServiceProvider();
Assert.Throws<InvalidOperationException>(sp.GetRequiredService<IMcpServer>);
sp.GetRequiredService<IMcpServer>();
}
[Fact]
public void Create_InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>("function", () => McpServerResource.Create((AIFunction)null!, new() { UriTemplate = "test://hello" }));
Assert.Throws<ArgumentNullException>("method", () => McpServerResource.Create((MethodInfo)null!));
Assert.Throws<ArgumentNullException>("method", () => McpServerResource.Create((MethodInfo)null!, typeof(object)));
Assert.Throws<ArgumentNullException>("targetType", () => McpServerResource.Create(typeof(McpServerResourceTests).GetMethod(nameof(Create_InvalidArgs_Throws))!, (Type)null!));
Assert.Throws<ArgumentNullException>("method", () => McpServerResource.Create((Delegate)null!));
Assert.NotNull(McpServerResource.Create(typeof(DisposableResourceType).GetMethod(nameof(DisposableResourceType.InstanceMethod))!, new DisposableResourceType()));
Assert.NotNull(McpServerResource.Create(typeof(DisposableResourceType).GetMethod(nameof(DisposableResourceType.StaticMethod))!));
Assert.Throws<ArgumentNullException>("target", () => McpServerResource.Create(typeof(DisposableResourceType).GetMethod(nameof(DisposableResourceType.InstanceMethod))!, target: null!));
}
[Fact]
public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported()
{
const string Name = "Hello";
McpServerResource t;
ReadResourceResult? result;
IMcpServer server = new Mock<IMcpServer>().Object;
t = McpServerResource.Create(() => "42", new() { Name = Name });
Assert.Equal($"resource://{Name}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((IMcpServer server) => "42", new() { Name = Name });
Assert.Equal($"resource://{Name}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((string arg1) => arg1, new() { Name = Name });
Assert.Equal($"resource://{Name}{{?arg1}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?arg1=wOrLd" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("wOrLd", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((string arg1, string? arg2 = null) => arg1 + arg2, new() { Name = Name });
Assert.Equal($"resource://{Name}{{?arg1,arg2}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?arg1=wo&arg2=rld" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("world", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((object a1, bool a2, char a3, byte a4, sbyte a5) => a1.ToString() + a2 + a3 + a4 + a5, new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1=hi&a2=true&a3=s&a4=12&a5=34" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("hiTrues1234", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((ushort a1, short a2, uint a3, int a4, ulong a5) => (a1 + a2 + a3 + a4 + (long)a5).ToString(), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1=10&a2=20&a3=30&a4=40&a5=50" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("150", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((long a1, float a2, double a3, decimal a4, TimeSpan a5) => a5.ToString(), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1=1&a2=2&a3=3&a4=4&a5=5" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("5.00:00:00", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((DateTime a1, DateTimeOffset a2, Uri a3, Guid a4, Version a5) => a4.ToString("N") + a5, new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1={DateTime.UtcNow:r}&a2={DateTimeOffset.UtcNow:r}&a3=http%3A%2F%2Ftest&a4=14e5f43d-0d41-47d6-8207-8249cf669e41&a5=1.2.3.4" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("14e5f43d0d4147d682078249cf669e411.2.3.4", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((Half a2, Int128 a3, UInt128 a4, IntPtr a5) => (a3 + (Int128)a4 + a5).ToString(), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a2=1.0&a3=3&a4=4&a5=5" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("12", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((UIntPtr a1, DateOnly a2, TimeOnly a3) => a1.ToString(), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a3}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1=123&a2=0001-02-03&a3=01%3A02%3A03" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("123", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((bool? a2, char? a3, byte? a4, sbyte? a5) => a2?.ToString() + a3 + a4 + a5, new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a2=true&a3=s&a4=12&a5=34" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("Trues1234", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((ushort? a1, short? a2, uint? a3, int? a4, ulong? a5) => (a1 + a2 + a3 + a4 + (long?)a5).ToString(), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1=10&a2=20&a3=30&a4=40&a5=50" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("150", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((long? a1, float? a2, double? a3, decimal? a4, TimeSpan? a5) => a5?.ToString(), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1=1&a2=2&a3=3&a4=4&a5=5" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("5.00:00:00", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((DateTime? a1, DateTimeOffset? a2, Guid? a4) => a4?.ToString("N"), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a4}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1={DateTime.UtcNow:r}&a2={DateTimeOffset.UtcNow:r}&a4=14e5f43d-0d41-47d6-8207-8249cf669e41" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("14e5f43d0d4147d682078249cf669e41", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((Half? a2, Int128? a3, UInt128? a4, IntPtr? a5) => (a3 + (Int128?)a4 + a5).ToString(), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a2=1.0&a3=3&a4=4&a5=5" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("12", ((TextResourceContents)result.Contents[0]).Text);
t = McpServerResource.Create((UIntPtr? a1, DateOnly? a2, TimeOnly? a3) => a1?.ToString(), new() { Name = Name });
Assert.Equal($"resource://{Name}{{?a1,a2,a3}}", t.ProtocolResourceTemplate.UriTemplate);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(server) { Params = new() { Uri = $"resource://{Name}?a1=123&a2=0001-02-03&a3=01%3A02%3A03" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("123", ((TextResourceContents)result.Contents[0]).Text);
}
[Theory]
[InlineData("resource://Hello?arg1=42&arg2=84")]
[InlineData("resource://Hello?arg1=42&arg2=84&arg3=123")]
[InlineData("resource://Hello#fragment")]
public async Task UriTemplate_NonMatchingUri_ReturnsNull(string uri)
{
McpServerResource t = McpServerResource.Create((string arg1) => arg1, new() { Name = "Hello" });
Assert.Equal("resource://Hello{?arg1}", t.ProtocolResourceTemplate.UriTemplate);
Assert.Null(await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object) { Params = new() { Uri = uri } },
TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("resource://Hello?arg1=test")]
[InlineData("resource://Hello?arg2=test")]
public async Task UriTemplate_MissingParameter_Throws(string uri)
{
McpServerResource t = McpServerResource.Create((string arg1, int arg2) => arg1, new() { Name = "Hello" });
Assert.Equal("resource://Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate);
await Assert.ThrowsAsync<ArgumentException>(async () => await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object) { Params = new() { Uri = uri } },
TestContext.Current.CancellationToken));
}
[Fact]
public async Task UriTemplate_MissingOptionalParameter_Succeeds()
{
McpServerResource t = McpServerResource.Create((string? arg1 = null, int? arg2 = null) => arg1 + arg2, new() { Name = "Hello" });
Assert.Equal("resource://Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate);
ReadResourceResult? result;
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object) { Params = new() { Uri = "resource://Hello" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("", ((TextResourceContents)result.Contents[0]).Text);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object) { Params = new() { Uri = "resource://Hello?arg1=first" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("first", ((TextResourceContents)result.Contents[0]).Text);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object) { Params = new() { Uri = "resource://Hello?arg2=42" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object) { Params = new() { Uri = "resource://Hello?arg1=first&arg2=42" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("first42", ((TextResourceContents)result.Contents[0]).Text);
}
[Fact]
public async Task SupportsIMcpServer()
{
Mock<IMcpServer> mockServer = new();
McpServerResource resource = McpServerResource.Create((IMcpServer server) =>
{
Assert.Same(mockServer.Object, server);
return "42";
}, new() { Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
}
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public async Task SupportsServiceFromDI(ServiceLifetime injectedArgumentLifetime)
{
MyService singletonService = new();
ServiceCollection sc = new();
switch (injectedArgumentLifetime)
{
case ServiceLifetime.Singleton:
sc.AddSingleton(singletonService);
break;
case ServiceLifetime.Scoped:
sc.AddScoped(_ => new MyService());
break;
case ServiceLifetime.Transient:
sc.AddTransient(_ => new MyService());
break;
}
sc.AddSingleton(services =>
{
return McpServerResource.Create((MyService actualMyService) =>
{
Assert.NotNull(actualMyService);
if (injectedArgumentLifetime == ServiceLifetime.Singleton)
{
Assert.Same(singletonService, actualMyService);
}
return "42";
}, new() { Services = services, Name = "Test" });
});
IServiceProvider services = sc.BuildServiceProvider();
McpServerResource resource = services.GetRequiredService<McpServerResource>();
Mock<IMcpServer> mockServer = new();
await Assert.ThrowsAsync<ArgumentNullException>(async () => await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken));
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Services = services, Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
}
[Fact]
public async Task SupportsOptionalServiceFromDI()
{
MyService expectedMyService = new();
ServiceCollection sc = new();
sc.AddSingleton(expectedMyService);
IServiceProvider services = sc.BuildServiceProvider();
McpServerResource resource = McpServerResource.Create((MyService? actualMyService = null) =>
{
Assert.Null(actualMyService);
return "42";
}, new() { Services = services, Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
}
[Fact]
public async Task SupportsDisposingInstantiatedDisposableTargets()
{
int before = DisposableResourceType.Disposals;
McpServerResource resource1 = McpServerResource.Create(
typeof(DisposableResourceType).GetMethod(nameof(DisposableResourceType.InstanceMethod))!,
typeof(DisposableResourceType));
var result = await resource1.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object) { Params = new() { Uri = "test://static/resource/instanceMethod" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("0", ((TextResourceContents)result.Contents[0]).Text);
Assert.Equal(1, DisposableResourceType.Disposals);
}
[Fact]
public async Task CanReturnReadResult()
{
Mock<IMcpServer> mockServer = new();
McpServerResource resource = McpServerResource.Create((IMcpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new ReadResourceResult() { Contents = new List<ResourceContents>() { new TextResourceContents() { Text = "hello" } } };
}, new() { Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
Assert.Equal("hello", ((TextResourceContents)result.Contents[0]).Text);
}
[Fact]
public async Task CanReturnResourceContents()
{
Mock<IMcpServer> mockServer = new();
McpServerResource resource = McpServerResource.Create((IMcpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new TextResourceContents() { Text = "hello" };
}, new() { Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
Assert.Equal("hello", ((TextResourceContents)result.Contents[0]).Text);
}
[Fact]
public async Task CanReturnCollectionOfResourceContents()
{
Mock<IMcpServer> mockServer = new();
McpServerResource resource = McpServerResource.Create((IMcpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new List<ResourceContents>()
{
new TextResourceContents() { Text = "hello" },
new BlobResourceContents() { Blob = Convert.ToBase64String(new byte[] { 1, 2, 3 }) },
};
}, new() { Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal(2, result.Contents.Count);
Assert.Equal("hello", ((TextResourceContents)result.Contents[0]).Text);
Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3 }), ((BlobResourceContents)result.Contents[1]).Blob);
}
[Fact]
public async Task CanReturnString()
{
Mock<IMcpServer> mockServer = new();
McpServerResource resource = McpServerResource.Create((IMcpServer server) =>
{
Assert.Same(mockServer.Object, server);
return "42";
}, new() { Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
}
[Fact]
public async Task CanReturnCollectionOfStrings()
{
Mock<IMcpServer> mockServer = new();
McpServerResource resource = McpServerResource.Create((IMcpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new List<string>() { "42", "43" };
}, new() { Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal(2, result.Contents.Count);
Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text);
Assert.Equal("43", ((TextResourceContents)result.Contents[1]).Text);
}
[Fact]
public async Task CanReturnDataContent()
{
Mock<IMcpServer> mockServer = new();
McpServerResource resource = McpServerResource.Create((IMcpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new DataContent(new byte[] { 0, 1, 2 }, "application/octet-stream");
}, new() { Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Contents);
Assert.Equal(Convert.ToBase64String(new byte[] { 0, 1, 2 }), ((BlobResourceContents)result.Contents[0]).Blob);
Assert.Equal("application/octet-stream", ((BlobResourceContents)result.Contents[0]).MimeType);
}
[Fact]
public async Task CanReturnCollectionOfAIContent()
{
Mock<IMcpServer> mockServer = new();
McpServerResource resource = McpServerResource.Create((IMcpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new List<AIContent>()
{
new TextContent("hello!"),
new DataContent(new byte[] { 4, 5, 6 }, "application/json"),
};
}, new() { Name = "Test" });
var result = await resource.ReadAsync(
new RequestContext<ReadResourceRequestParams>(mockServer.Object) { Params = new() { Uri = "resource://Test" } },
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal(2, result.Contents.Count);
Assert.Equal("hello!", ((TextResourceContents)result.Contents[0]).Text);
Assert.Equal(Convert.ToBase64String(new byte[] { 4, 5, 6 }), ((BlobResourceContents)result.Contents[1]).Blob);
Assert.Equal("application/json", ((BlobResourceContents)result.Contents[1]).MimeType);
}
private sealed class MyService;
private class DisposableResourceType : IDisposable
{
public static int Disposals { get; private set; }
public void Dispose() => Disposals++;
[McpServerResource(UriTemplate = "test://static/resource/instanceMethod")]
public object InstanceMethod() => Disposals.ToString();
[McpServerResource(UriTemplate = "test://static/resource/staticMethod")]
public static object StaticMethod() => "42";
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(DisposableResourceType))]
partial class JsonContext6 : JsonSerializerContext;
}

View file

@ -101,7 +101,7 @@ public class McpServerTests : LoggedTest
await using var server = McpServerFactory.Create(transport, _options, LoggerFactory);
SetClientCapabilities(server, new ClientCapabilities());
var action = () => server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None);
var action = async () => await server.RequestSamplingAsync(new CreateMessageRequestParams { Messages = [] }, CancellationToken.None);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(action);
@ -138,7 +138,7 @@ public class McpServerTests : LoggedTest
SetClientCapabilities(server, new ClientCapabilities());
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None));
await Assert.ThrowsAsync<InvalidOperationException>(async () => await server.RequestRootsAsync(new ListRootsRequestParams(), CancellationToken.None));
}
[Fact]
@ -294,7 +294,7 @@ public class McpServerTests : LoggedTest
[Fact]
public async Task Can_Handle_Resources_List_Requests_Throws_Exception_If_No_Handler_Assigned()
{
await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, RequestMethods.ResourcesList, "ListResources handler not configured");
await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, RequestMethods.ResourcesList, "ListResources handler not configured");
}
[Fact]
@ -331,7 +331,7 @@ public class McpServerTests : LoggedTest
[Fact]
public async Task Can_Handle_Resources_Read_Requests_Throws_Exception_If_No_Handler_Assigned()
{
await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, RequestMethods.ResourcesRead, "ReadResource handler not configured");
await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Resources = new() }, RequestMethods.ResourcesRead, "ReadResource handler not configured");
}
[Fact]
@ -366,7 +366,7 @@ public class McpServerTests : LoggedTest
[Fact]
public async Task Can_Handle_List_Prompts_Requests_Throws_Exception_If_No_Handler_Assigned()
{
await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, RequestMethods.PromptsList, "ListPrompts handler not configured");
await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, RequestMethods.PromptsList, "ListPrompts handler not configured");
}
[Fact]
@ -394,7 +394,7 @@ public class McpServerTests : LoggedTest
[Fact]
public async Task Can_Handle_Get_Prompts_Requests_Throws_Exception_If_No_Handler_Assigned()
{
await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, RequestMethods.PromptsGet, "GetPrompt handler not configured");
await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Prompts = new() }, RequestMethods.PromptsGet, "GetPrompt handler not configured");
}
[Fact]
@ -429,7 +429,7 @@ public class McpServerTests : LoggedTest
[Fact]
public async Task Can_Handle_List_Tools_Requests_Throws_Exception_If_No_Handler_Assigned()
{
await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsList, "ListTools handler not configured");
await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsList, "ListTools handler not configured");
}
[Fact]
@ -464,7 +464,7 @@ public class McpServerTests : LoggedTest
[Fact]
public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_Assigned()
{
await Throws_Exception_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured");
await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured");
}
private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action<McpServerOptions>? configureOptions, Action<JsonNode?> assertResult)
@ -502,12 +502,13 @@ public class McpServerTests : LoggedTest
await runTask;
}
private async Task Throws_Exception_If_No_Handler_Assigned(ServerCapabilities serverCapabilities, string method, string expectedError)
private async Task Succeeds_Even_If_No_Handler_Assigned(ServerCapabilities serverCapabilities, string method, string expectedError)
{
await using var transport = new TestServerTransport();
var options = CreateOptions(serverCapabilities);
Assert.Throws<InvalidOperationException>(() => McpServerFactory.Create(transport, options, LoggerFactory));
var server = McpServerFactory.Create(transport, options, LoggerFactory);
await server.DisposeAsync();
}
[Fact]

View file

@ -1,7 +1,6 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
using System.Net;
namespace ModelContextProtocol.Tests.Transport;