Skip to content

Commit 39018fe

Browse files
committed
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.
1 parent c750f09 commit 39018fe

File tree

52 files changed

+5361
-331
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+5361
-331
lines changed

THIRD-PARTY-NOTICES.txt

+17
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
5959
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
6060
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
6161
SOFTWARE.
62+
63+
License notice for URI Template Tests
64+
-------------------------------------
65+
66+
Copyright 2011- The Authors
67+
68+
Licensed under the Apache License, Version 2.0 (the "License");
69+
you may not use this file except in compliance with the License.
70+
You may obtain a copy of the License at
71+
72+
http://www.apache.org/licenses/LICENSE-2.0
73+
74+
Unless required by applicable law or agreed to in writing, software
75+
distributed under the License is distributed on an "AS IS" BASIS,
76+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
77+
See the License for the specific language governing permissions and
78+
limitations under the License.

src/Common/Polyfills/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs

+617
Large diffs are not rendered by default.

src/ModelContextProtocol/Client/McpClient.cs

-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using ModelContextProtocol.Shared;
66
using ModelContextProtocol.Utils.Json;
77
using System.Text.Json;
8-
using System.Threading;
98

109
namespace ModelContextProtocol.Client;
1110

src/ModelContextProtocol/Client/McpClientExtensions.cs

+55-36
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public static Task PingAsync(this IMcpClient client, CancellationToken cancellat
5050
parameters: null,
5151
McpJsonUtilities.JsonContext.Default.Object!,
5252
McpJsonUtilities.JsonContext.Default.Object,
53-
cancellationToken: cancellationToken);
53+
cancellationToken: cancellationToken).AsTask();
5454
}
5555

5656
/// <summary>
@@ -92,7 +92,7 @@ public static Task PingAsync(this IMcpClient client, CancellationToken cancellat
9292
/// </code>
9393
/// </example>
9494
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
95-
public static async Task<IList<McpClientTool>> ListToolsAsync(
95+
public static async ValueTask<IList<McpClientTool>> ListToolsAsync(
9696
this IMcpClient client,
9797
JsonSerializerOptions? serializerOptions = null,
9898
CancellationToken cancellationToken = default)
@@ -205,7 +205,7 @@ public static async IAsyncEnumerable<McpClientTool> EnumerateToolsAsync(
205205
/// </para>
206206
/// </remarks>
207207
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
208-
public static async Task<IList<McpClientPrompt>> ListPromptsAsync(
208+
public static async ValueTask<IList<McpClientPrompt>> ListPromptsAsync(
209209
this IMcpClient client, CancellationToken cancellationToken = default)
210210
{
211211
Throw.IfNull(client);
@@ -311,7 +311,7 @@ public static async IAsyncEnumerable<McpClientPrompt> EnumeratePromptsAsync(
311311
/// </remarks>
312312
/// <exception cref="McpException">Thrown when the prompt does not exist, when required arguments are missing, or when the server encounters an error processing the prompt.</exception>
313313
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
314-
public static Task<GetPromptResult> GetPromptAsync(
314+
public static ValueTask<GetPromptResult> GetPromptAsync(
315315
this IMcpClient client,
316316
string name,
317317
IReadOnlyDictionary<string, object?>? arguments = null,
@@ -349,12 +349,12 @@ public static Task<GetPromptResult> GetPromptAsync(
349349
/// </para>
350350
/// </remarks>
351351
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
352-
public static async Task<IList<ResourceTemplate>> ListResourceTemplatesAsync(
352+
public static async ValueTask<IList<McpClientResourceTemplate>> ListResourceTemplatesAsync(
353353
this IMcpClient client, CancellationToken cancellationToken = default)
354354
{
355355
Throw.IfNull(client);
356356

357-
List<ResourceTemplate>? templates = null;
357+
List<McpClientResourceTemplate>? resourceTemplates = null;
358358

359359
string? cursor = null;
360360
do
@@ -366,20 +366,17 @@ public static async Task<IList<ResourceTemplate>> ListResourceTemplatesAsync(
366366
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
367367
cancellationToken: cancellationToken).ConfigureAwait(false);
368368

369-
if (templates is null)
370-
{
371-
templates = templateResults.ResourceTemplates;
372-
}
373-
else
369+
resourceTemplates ??= new List<McpClientResourceTemplate>(templateResults.ResourceTemplates.Count);
370+
foreach (var template in templateResults.ResourceTemplates)
374371
{
375-
templates.AddRange(templateResults.ResourceTemplates);
372+
resourceTemplates.Add(new McpClientResourceTemplate(client, template));
376373
}
377374

378375
cursor = templateResults.NextCursor;
379376
}
380377
while (cursor is not null);
381378

382-
return templates;
379+
return resourceTemplates;
383380
}
384381

385382
/// <summary>
@@ -395,7 +392,7 @@ public static async Task<IList<ResourceTemplate>> ListResourceTemplatesAsync(
395392
/// with cursors if the server responds with templates split across multiple responses.
396393
/// </para>
397394
/// <para>
398-
/// Every iteration through the returned <see cref="IAsyncEnumerable{ResourceTemplate}"/>
395+
/// Every iteration through the returned <see cref="IAsyncEnumerable{McpClientResourceTemplate}"/>
399396
/// will result in re-querying the server and yielding the sequence of available resource templates.
400397
/// </para>
401398
/// </remarks>
@@ -409,7 +406,7 @@ public static async Task<IList<ResourceTemplate>> ListResourceTemplatesAsync(
409406
/// </code>
410407
/// </example>
411408
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
412-
public static async IAsyncEnumerable<ResourceTemplate> EnumerateResourceTemplatesAsync(
409+
public static async IAsyncEnumerable<McpClientResourceTemplate> EnumerateResourceTemplatesAsync(
413410
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
414411
{
415412
Throw.IfNull(client);
@@ -424,9 +421,9 @@ public static async IAsyncEnumerable<ResourceTemplate> EnumerateResourceTemplate
424421
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
425422
cancellationToken: cancellationToken).ConfigureAwait(false);
426423

427-
foreach (var template in templateResults.ResourceTemplates)
424+
foreach (var templateResult in templateResults.ResourceTemplates)
428425
{
429-
yield return template;
426+
yield return new McpClientResourceTemplate(client, templateResult);
430427
}
431428

432429
cursor = templateResults.NextCursor;
@@ -463,12 +460,12 @@ public static async IAsyncEnumerable<ResourceTemplate> EnumerateResourceTemplate
463460
/// </code>
464461
/// </example>
465462
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
466-
public static async Task<IList<Resource>> ListResourcesAsync(
463+
public static async ValueTask<IList<McpClientResource>> ListResourcesAsync(
467464
this IMcpClient client, CancellationToken cancellationToken = default)
468465
{
469466
Throw.IfNull(client);
470467

471-
List<Resource>? resources = null;
468+
List<McpClientResource>? resources = null;
472469

473470
string? cursor = null;
474471
do
@@ -480,13 +477,10 @@ public static async Task<IList<Resource>> ListResourcesAsync(
480477
McpJsonUtilities.JsonContext.Default.ListResourcesResult,
481478
cancellationToken: cancellationToken).ConfigureAwait(false);
482479

483-
if (resources is null)
484-
{
485-
resources = resourceResults.Resources;
486-
}
487-
else
480+
resources ??= new List<McpClientResource>(resourceResults.Resources.Count);
481+
foreach (var resource in resourceResults.Resources)
488482
{
489-
resources.AddRange(resourceResults.Resources);
483+
resources.Add(new McpClientResource(client, resource));
490484
}
491485

492486
cursor = resourceResults.NextCursor;
@@ -509,7 +503,7 @@ public static async Task<IList<Resource>> ListResourcesAsync(
509503
/// with cursors if the server responds with resources split across multiple responses.
510504
/// </para>
511505
/// <para>
512-
/// Every iteration through the returned <see cref="IAsyncEnumerable{Resource}"/>
506+
/// Every iteration through the returned <see cref="IAsyncEnumerable{McpClientResource}"/>
513507
/// will result in re-querying the server and yielding the sequence of available resources.
514508
/// </para>
515509
/// </remarks>
@@ -523,7 +517,7 @@ public static async Task<IList<Resource>> ListResourcesAsync(
523517
/// </code>
524518
/// </example>
525519
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
526-
public static async IAsyncEnumerable<Resource> EnumerateResourcesAsync(
520+
public static async IAsyncEnumerable<McpClientResource> EnumerateResourcesAsync(
527521
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
528522
{
529523
Throw.IfNull(client);
@@ -540,7 +534,7 @@ public static async IAsyncEnumerable<Resource> EnumerateResourcesAsync(
540534

541535
foreach (var resource in resourceResults.Resources)
542536
{
543-
yield return resource;
537+
yield return new McpClientResource(client, resource);
544538
}
545539

546540
cursor = resourceResults.NextCursor;
@@ -557,7 +551,7 @@ public static async IAsyncEnumerable<Resource> EnumerateResourcesAsync(
557551
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
558552
/// <exception cref="ArgumentNullException"><paramref name="uri"/> is <see langword="null"/>.</exception>
559553
/// <exception cref="ArgumentException"><paramref name="uri"/> is empty or composed entirely of whitespace.</exception>
560-
public static Task<ReadResourceResult> ReadResourceAsync(
554+
public static ValueTask<ReadResourceResult> ReadResourceAsync(
561555
this IMcpClient client, string uri, CancellationToken cancellationToken = default)
562556
{
563557
Throw.IfNull(client);
@@ -579,7 +573,7 @@ public static Task<ReadResourceResult> ReadResourceAsync(
579573
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
580574
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
581575
/// <exception cref="ArgumentNullException"><paramref name="uri"/> is <see langword="null"/>.</exception>
582-
public static Task<ReadResourceResult> ReadResourceAsync(
576+
public static ValueTask<ReadResourceResult> ReadResourceAsync(
583577
this IMcpClient client, Uri uri, CancellationToken cancellationToken = default)
584578
{
585579
Throw.IfNull(client);
@@ -588,6 +582,31 @@ public static Task<ReadResourceResult> ReadResourceAsync(
588582
return ReadResourceAsync(client, uri.ToString(), cancellationToken);
589583
}
590584

585+
/// <summary>
586+
/// Reads a resource from the server.
587+
/// </summary>
588+
/// <param name="client">The client instance used to communicate with the MCP server.</param>
589+
/// <param name="uriTemplate">The uri template of the resource.</param>
590+
/// <param name="arguments">Arguments to use to format <paramref name="uriTemplate"/>.</param>
591+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
592+
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
593+
/// <exception cref="ArgumentNullException"><paramref name="uriTemplate"/> is <see langword="null"/>.</exception>
594+
/// <exception cref="ArgumentException"><paramref name="uriTemplate"/> is empty or composed entirely of whitespace.</exception>
595+
public static ValueTask<ReadResourceResult> ReadResourceAsync(
596+
this IMcpClient client, string uriTemplate, IReadOnlyDictionary<string, object?> arguments, CancellationToken cancellationToken = default)
597+
{
598+
Throw.IfNull(client);
599+
Throw.IfNullOrWhiteSpace(uriTemplate);
600+
Throw.IfNull(arguments);
601+
602+
return client.SendRequestAsync(
603+
RequestMethods.ResourcesRead,
604+
new() { Uri = UriTemplate.FormatUri(uriTemplate, arguments) },
605+
McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams,
606+
McpJsonUtilities.JsonContext.Default.ReadResourceResult,
607+
cancellationToken: cancellationToken);
608+
}
609+
591610
/// <summary>
592611
/// Requests completion suggestions for a prompt argument or resource reference.
593612
/// </summary>
@@ -617,7 +636,7 @@ public static Task<ReadResourceResult> ReadResourceAsync(
617636
/// <exception cref="ArgumentNullException"><paramref name="argumentName"/> is <see langword="null"/>.</exception>
618637
/// <exception cref="ArgumentException"><paramref name="argumentName"/> is empty or composed entirely of whitespace.</exception>
619638
/// <exception cref="McpException">The server returned an error response.</exception>
620-
public static Task<CompleteResult> CompleteAsync(this IMcpClient client, Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default)
639+
public static ValueTask<CompleteResult> CompleteAsync(this IMcpClient client, Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default)
621640
{
622641
Throw.IfNull(client);
623642
Throw.IfNull(reference);
@@ -675,7 +694,7 @@ public static Task SubscribeToResourceAsync(this IMcpClient client, string uri,
675694
new() { Uri = uri },
676695
McpJsonUtilities.JsonContext.Default.SubscribeRequestParams,
677696
McpJsonUtilities.JsonContext.Default.EmptyResult,
678-
cancellationToken: cancellationToken);
697+
cancellationToken: cancellationToken).AsTask();
679698
}
680699

681700
/// <summary>
@@ -744,7 +763,7 @@ public static Task UnsubscribeFromResourceAsync(this IMcpClient client, string u
744763
new() { Uri = uri },
745764
McpJsonUtilities.JsonContext.Default.UnsubscribeRequestParams,
746765
McpJsonUtilities.JsonContext.Default.EmptyResult,
747-
cancellationToken: cancellationToken);
766+
cancellationToken: cancellationToken).AsTask();
748767
}
749768

750769
/// <summary>
@@ -813,7 +832,7 @@ public static Task UnsubscribeFromResourceAsync(this IMcpClient client, Uri uri,
813832
/// });
814833
/// </code>
815834
/// </example>
816-
public static Task<CallToolResponse> CallToolAsync(
835+
public static ValueTask<CallToolResponse> CallToolAsync(
817836
this IMcpClient client,
818837
string toolName,
819838
IReadOnlyDictionary<string, object?>? arguments = null,
@@ -842,7 +861,7 @@ public static Task<CallToolResponse> CallToolAsync(
842861
McpJsonUtilities.JsonContext.Default.CallToolResponse,
843862
cancellationToken: cancellationToken);
844863

845-
static async Task<CallToolResponse> SendRequestWithProgressAsync(
864+
static async ValueTask<CallToolResponse> SendRequestWithProgressAsync(
846865
IMcpClient client,
847866
string toolName,
848867
IReadOnlyDictionary<string, object?>? arguments,
@@ -1061,7 +1080,7 @@ public static Task SetLoggingLevel(this IMcpClient client, LoggingLevel level, C
10611080
new() { Level = level },
10621081
McpJsonUtilities.JsonContext.Default.SetLevelRequestParams,
10631082
McpJsonUtilities.JsonContext.Default.EmptyResult,
1064-
cancellationToken: cancellationToken);
1083+
cancellationToken: cancellationToken).AsTask();
10651084
}
10661085

10671086
/// <summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using ModelContextProtocol.Protocol.Types;
2+
3+
namespace ModelContextProtocol.Client;
4+
5+
/// <summary>
6+
/// Represents a named resource that can be retrieved from an MCP server.
7+
/// </summary>
8+
/// <remarks>
9+
/// <para>
10+
/// This class provides a client-side wrapper around a resource defined on an MCP server. It allows
11+
/// retrieving the resource's content by sending a request to the server with the resource's URI.
12+
/// Instances of this class are typically obtained by calling <see cref="McpClientExtensions.ListResourcesAsync"/>
13+
/// or <see cref="McpClientExtensions.EnumerateResourcesAsync"/>.
14+
/// </para>
15+
/// </remarks>
16+
public sealed class McpClientResource
17+
{
18+
private readonly IMcpClient _client;
19+
20+
internal McpClientResource(IMcpClient client, Resource resource)
21+
{
22+
_client = client;
23+
ProtocolResource = resource;
24+
}
25+
26+
/// <summary>Gets the underlying protocol <see cref="Resource"/> type for this instance.</summary>
27+
/// <remarks>
28+
/// <para>
29+
/// This property provides direct access to the underlying protocol representation of the resource,
30+
/// which can be useful for advanced scenarios or when implementing custom MCP client extensions.
31+
/// </para>
32+
/// <para>
33+
/// For most common use cases, you can use the more convenient <see cref="Name"/> and
34+
/// <see cref="Description"/> properties instead of accessing the <see cref="ProtocolResource"/> directly.
35+
/// </para>
36+
/// </remarks>
37+
public Resource ProtocolResource { get; }
38+
39+
/// <summary>Gets the URI of the resource.</summary>
40+
public string Uri => ProtocolResource.Uri;
41+
42+
/// <summary>Gets the name of the resource.</summary>
43+
public string Name => ProtocolResource.Name;
44+
45+
/// <summary>Gets a description of the resource.</summary>
46+
public string? Description => ProtocolResource.Description;
47+
48+
/// <summary>Gets a media (MIME) type of the resource.</summary>
49+
public string? MimeType => ProtocolResource.MimeType;
50+
51+
/// <summary>
52+
/// Gets this resource's content by sending a request to the server.
53+
/// </summary>
54+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
55+
/// <returns>A <see cref="ValueTask{ReadResourceResult}"/> containing the resource's result with content and messages.</returns>
56+
/// <remarks>
57+
/// <para>
58+
/// This is a convenience method that internally calls <see cref="McpClientExtensions.ReadResourceAsync(IMcpClient, string, CancellationToken)"/>.
59+
/// </para>
60+
/// </remarks>
61+
public ValueTask<ReadResourceResult> ReadAsync(
62+
CancellationToken cancellationToken = default) =>
63+
_client.ReadResourceAsync(Uri, cancellationToken);
64+
}

0 commit comments

Comments
 (0)