Improve progress reporting (#145)

* Improve progress reporting

- Enable injecting an `IProgress<>` into a tool call to report progress when the client has supplied a progress token
- Add a custom type for ProgressToken and ProgressNotification to map to the schema
- Simplify RequestId to match ProgressToken's shape
- Serialize StdioClientTransport's SendMessageAsync implementation
- Add strongly-typed names for all request methods
- Change all request IDs created by McpJsonRpcEndpoint to include a guid for that endpoint

* Address PR feedback
This commit is contained in:
Stephen Toub 2025-03-31 09:33:16 -04:00 • committed by GitHub
commit 2c75acded8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 627 additions and 228 deletions

View file

@ -42,7 +42,7 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
} }
SetRequestHandler<CreateMessageRequestParams, CreateMessageResult>( SetRequestHandler<CreateMessageRequestParams, CreateMessageResult>(
"sampling/createMessage", RequestMethods.SamplingCreateMessage,
(request, ct) => samplingHandler(request, ct)); (request, ct) => samplingHandler(request, ct));
} }
@ -54,7 +54,7 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
} }
SetRequestHandler<ListRootsRequestParams, ListRootsResult>( SetRequestHandler<ListRootsRequestParams, ListRootsResult>(
"roots/list", RequestMethods.RootsList,
(request, ct) => rootsHandler(request, ct)); (request, ct) => rootsHandler(request, ct));
} }
} }
@ -89,21 +89,21 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
initializationCts.CancelAfter(_options.InitializationTimeout); initializationCts.CancelAfter(_options.InitializationTimeout);
try try
{ {
// Send initialize request // Send initialize request
var initializeResponse = await SendRequestAsync<InitializeResult>( var initializeResponse = await SendRequestAsync<InitializeResult>(
new JsonRpcRequest new JsonRpcRequest
{
Method = RequestMethods.Initialize,
Params = new InitializeRequestParams()
{ {
Method = "initialize", ProtocolVersion = _options.ProtocolVersion,
Params = new InitializeRequestParams() Capabilities = _options.Capabilities ?? new ClientCapabilities(),
{ ClientInfo = _options.ClientInfo
ProtocolVersion = _options.ProtocolVersion, }
Capabilities = _options.Capabilities ?? new ClientCapabilities(), },
ClientInfo = _options.ClientInfo, initializationCts.Token).ConfigureAwait(false);
}
},
initializationCts.Token).ConfigureAwait(false);
// Store server information // Store server information
_logger.ServerCapabilitiesReceived(EndpointName, _logger.ServerCapabilitiesReceived(EndpointName,
@ -123,7 +123,7 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
// Send initialized notification // Send initialized notification
await SendMessageAsync( await SendMessageAsync(
new JsonRpcNotification { Method = "notifications/initialized" }, new JsonRpcNotification { Method = NotificationMethods.InitializedNotification },
initializationCts.Token).ConfigureAwait(false); initializationCts.Token).ConfigureAwait(false);
} }
catch (OperationCanceledException) when (initializationCts.IsCancellationRequested) catch (OperationCanceledException) when (initializationCts.IsCancellationRequested)

View file

@ -41,7 +41,7 @@ public static class McpClientExtensions
Throw.IfNull(client); Throw.IfNull(client);
return client.SendRequestAsync<dynamic>( return client.SendRequestAsync<dynamic>(
CreateRequest("ping", null), CreateRequest(RequestMethods.Ping, null),
cancellationToken); cancellationToken);
} }
@ -61,7 +61,7 @@ public static class McpClientExtensions
do do
{ {
var toolResults = await client.SendRequestAsync<ListToolsResult>( var toolResults = await client.SendRequestAsync<ListToolsResult>(
CreateRequest("tools/list", CreateCursorDictionary(cursor)), CreateRequest(RequestMethods.ToolsList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
tools ??= new List<McpClientTool>(toolResults.Tools.Count); tools ??= new List<McpClientTool>(toolResults.Tools.Count);
@ -96,7 +96,7 @@ public static class McpClientExtensions
do do
{ {
var toolResults = await client.SendRequestAsync<ListToolsResult>( var toolResults = await client.SendRequestAsync<ListToolsResult>(
CreateRequest("tools/list", CreateCursorDictionary(cursor)), CreateRequest(RequestMethods.ToolsList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
foreach (var tool in toolResults.Tools) foreach (var tool in toolResults.Tools)
@ -126,7 +126,7 @@ public static class McpClientExtensions
do do
{ {
var promptResults = await client.SendRequestAsync<ListPromptsResult>( var promptResults = await client.SendRequestAsync<ListPromptsResult>(
CreateRequest("prompts/list", CreateCursorDictionary(cursor)), CreateRequest(RequestMethods.PromptsList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
if (prompts is null) if (prompts is null)
@ -164,7 +164,7 @@ public static class McpClientExtensions
do do
{ {
var promptResults = await client.SendRequestAsync<ListPromptsResult>( var promptResults = await client.SendRequestAsync<ListPromptsResult>(
CreateRequest("prompts/list", CreateCursorDictionary(cursor)), CreateRequest(RequestMethods.PromptsList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
foreach (var prompt in promptResults.Prompts) foreach (var prompt in promptResults.Prompts)
@ -192,7 +192,7 @@ public static class McpClientExtensions
Throw.IfNullOrWhiteSpace(name); Throw.IfNullOrWhiteSpace(name);
return client.SendRequestAsync<GetPromptResult>( return client.SendRequestAsync<GetPromptResult>(
CreateRequest("prompts/get", CreateParametersDictionary(name, arguments)), CreateRequest(RequestMethods.PromptsGet, CreateParametersDictionary(name, arguments)),
cancellationToken); cancellationToken);
} }
@ -213,7 +213,7 @@ public static class McpClientExtensions
do do
{ {
var templateResults = await client.SendRequestAsync<ListResourceTemplatesResult>( var templateResults = await client.SendRequestAsync<ListResourceTemplatesResult>(
CreateRequest("resources/templates/list", CreateCursorDictionary(cursor)), CreateRequest(RequestMethods.ResourcesTemplatesList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
if (templates is null) if (templates is null)
@ -251,7 +251,7 @@ public static class McpClientExtensions
do do
{ {
var templateResults = await client.SendRequestAsync<ListResourceTemplatesResult>( var templateResults = await client.SendRequestAsync<ListResourceTemplatesResult>(
CreateRequest("resources/templates/list", CreateCursorDictionary(cursor)), CreateRequest(RequestMethods.ResourcesTemplatesList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
foreach (var template in templateResults.ResourceTemplates) foreach (var template in templateResults.ResourceTemplates)
@ -281,7 +281,7 @@ public static class McpClientExtensions
do do
{ {
var resourceResults = await client.SendRequestAsync<ListResourcesResult>( var resourceResults = await client.SendRequestAsync<ListResourcesResult>(
CreateRequest("resources/list", CreateCursorDictionary(cursor)), CreateRequest(RequestMethods.ResourcesList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
if (resources is null) if (resources is null)
@ -319,7 +319,7 @@ public static class McpClientExtensions
do do
{ {
var resourceResults = await client.SendRequestAsync<ListResourcesResult>( var resourceResults = await client.SendRequestAsync<ListResourcesResult>(
CreateRequest("resources/list", CreateCursorDictionary(cursor)), CreateRequest(RequestMethods.ResourcesList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
foreach (var resource in resourceResults.Resources) foreach (var resource in resourceResults.Resources)
@ -345,7 +345,7 @@ public static class McpClientExtensions
Throw.IfNullOrWhiteSpace(uri); Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<ReadResourceResult>( return client.SendRequestAsync<ReadResourceResult>(
CreateRequest("resources/read", new() { ["uri"] = uri }), CreateRequest(RequestMethods.ResourcesRead, new() { ["uri"] = uri }),
cancellationToken); cancellationToken);
} }
@ -369,7 +369,7 @@ public static class McpClientExtensions
} }
return client.SendRequestAsync<CompleteResult>( return client.SendRequestAsync<CompleteResult>(
CreateRequest("completion/complete", new() CreateRequest(RequestMethods.CompletionComplete, new()
{ {
["ref"] = reference, ["ref"] = reference,
["argument"] = new Argument { Name = argumentName, Value = argumentValue } ["argument"] = new Argument { Name = argumentName, Value = argumentValue }
@ -389,7 +389,7 @@ public static class McpClientExtensions
Throw.IfNullOrWhiteSpace(uri); Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<EmptyResult>( return client.SendRequestAsync<EmptyResult>(
CreateRequest("resources/subscribe", new() { ["uri"] = uri }), CreateRequest(RequestMethods.ResourcesSubscribe, new() { ["uri"] = uri }),
cancellationToken); cancellationToken);
} }
@ -405,7 +405,7 @@ public static class McpClientExtensions
Throw.IfNullOrWhiteSpace(uri); Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<EmptyResult>( return client.SendRequestAsync<EmptyResult>(
CreateRequest("resources/unsubscribe", new() { ["uri"] = uri }), CreateRequest(RequestMethods.ResourcesUnsubscribe, new() { ["uri"] = uri }),
cancellationToken); cancellationToken);
} }
@ -424,7 +424,7 @@ public static class McpClientExtensions
Throw.IfNull(toolName); Throw.IfNull(toolName);
return client.SendRequestAsync<CallToolResponse>( return client.SendRequestAsync<CallToolResponse>(
CreateRequest("tools/call", CreateParametersDictionary(toolName, arguments)), CreateRequest(RequestMethods.ToolsCall, CreateParametersDictionary(toolName, arguments)),
cancellationToken); cancellationToken);
} }
@ -560,7 +560,7 @@ public static class McpClientExtensions
Throw.IfNull(client); Throw.IfNull(client);
return client.SendRequestAsync<EmptyResult>( return client.SendRequestAsync<EmptyResult>(
CreateRequest("logging/setLevel", new() { ["level"] = level }), CreateRequest(RequestMethods.LoggingSetLevel, new() { ["level"] = level }),
cancellationToken); cancellationToken);
} }

View file

@ -0,0 +1,12 @@
namespace ModelContextProtocol;
/// <summary>Provides an <see cref="IProgress{ProgressNotificationValue}"/> that's a nop.</summary>
internal sealed class NullProgress : IProgress<ProgressNotificationValue>
{
public static NullProgress Instance { get; } = new();
/// <inheritdoc />
public void Report(ProgressNotificationValue value)
{
}
}

View file

@ -0,0 +1,14 @@
namespace ModelContextProtocol;
/// <summary>Provides a progress value that can be sent using <see cref="IProgress{ProgressNotificationValue}"/>.</summary>
public record struct ProgressNotificationValue
{
/// <summary>Gets or sets the progress thus far.</summary>
public required float Progress { get; init; }
/// <summary>Gets or sets the total number of items to process (or total progress required), if known.</summary>
public float? Total { get; init; }
/// <summary>Gets or sets an optional message describing the current progress.</summary>
public string? Message { get; init; }
}

View file

@ -34,4 +34,27 @@ public static class NotificationMethods
/// Sent by the server when a log message is generated. /// Sent by the server when a log message is generated.
/// </summary> /// </summary>
public const string LoggingMessageNotification = "notifications/message"; public const string LoggingMessageNotification = "notifications/message";
/// <summary>
/// Sent from the client to the server after initialization has finished.
/// </summary>
public const string InitializedNotification = "notifications/initialized";
/// <summary>
/// Sent to inform the receiver of a progress update for a long-running request.
/// </summary>
public const string ProgressNotification = "notifications/progress";
/// <summary>
/// Sent by either side to indicate that it is cancelling a previously-issued request.
/// </summary>
/// <remarks>
/// The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification
/// MAY arrive after the request has already finished.
///
/// This notification indicates that the result will be unused, so any associated processing SHOULD cease.
///
/// A client MUST NOT attempt to cancel its `initialize` request.".
/// </remarks>
public const string CancelledNotification = "notifications/cancelled";
} }

View file

@ -1,43 +0,0 @@
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>Provides names of standard operations for use with registering handlers.</summary>
/// <remarks>
/// These values should not be inspected or relied on for their exact values.
/// They serve only as opaque keys. They will be stable for the lifetime of a process
/// but may change between versions of this library.
/// </remarks>
public static class OperationNames
{
/// <summary>Gets the name of the sampling operation.</summary>
public static string Sampling { get; } = "operation/sampling";
/// <summary>Gets the name of the roots operation.</summary>
public static string Roots { get; } = "operation/roots";
/// <summary>Gets the name of the list tools operation.</summary>
public static string ListTools { get; } = "operation/listTools";
/// <summary>Gets the name of the call tool operation.</summary>
public static string CallTool { get; } = "operation/callTool";
/// <summary>Gets the name of the list prompts operation.</summary>
public static string ListPrompts { get; } = "operation/listPrompts";
/// <summary>Gets the name of the get prompt operation.</summary>
public static string GetPrompt { get; } = "operation/getPrompt";
/// <summary>Gets the name of the list resources operation.</summary>
public static string ListResources { get; } = "operation/listResources";
/// <summary>Gets the name of the read resource operation.</summary>
public static string ReadResource { get; } = "operation/readResource";
/// <summary>Gets the name of the get completion operation.</summary>
public static string GetCompletion { get; } = "operation/getCompletion";
/// <summary>Gets the name of the subscribe to resources operation.</summary>
public static string SubscribeToResources { get; } = "operation/subscribeToResources";
/// <summary>Gets the name of the subscribe to resources operation.</summary>
public static string UnsubscribeFromResources { get; } = "operation/unsubscribeFromResources";
}

View file

@ -0,0 +1,108 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// An out-of-band notification used to inform the receiver of a progress update for a long-running request.
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/">See the schema for details</see>
/// </summary>
[JsonConverter(typeof(Converter))]
public class ProgressNotification
{
/// <summary>
/// The progress token which was given in the initial request, used to associate this notification with the request that is proceeding.
/// </summary>
public required ProgressToken ProgressToken { get; init; }
/// <summary>
/// The progress thus far. This should increase every time progress is made, even if the total is unknown.
/// </summary>
public required ProgressNotificationValue Progress { get; init; }
/// <summary>Provides a <see cref="JsonConverter"/> for <see cref="ProgressNotification"/>.</summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class Converter : JsonConverter<ProgressNotification>
{
/// <inheritdoc />
public override ProgressNotification? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
ProgressToken? progressToken = null;
float? progress = null;
float? total = null;
string? message = null;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
if (reader.TokenType == JsonTokenType.PropertyName)
{
var propertyName = reader.GetString();
reader.Read();
switch (propertyName)
{
case "progressToken":
progressToken = (ProgressToken)JsonSerializer.Deserialize(ref reader, options.GetTypeInfo(typeof(ProgressToken)))!;
break;
case "progress":
progress = reader.GetSingle();
break;
case "total":
total = reader.GetSingle();
break;
case "message":
message = reader.GetString();
break;
}
}
}
if (progress is null)
{
throw new JsonException("Missing required property 'progress'.");
}
if (progressToken is null)
{
throw new JsonException("Missing required property 'progressToken'.");
}
return new ProgressNotification
{
ProgressToken = progressToken.GetValueOrDefault(),
Progress = new ProgressNotificationValue()
{
Progress = progress.GetValueOrDefault(),
Total = total,
Message = message
}
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, ProgressNotification value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WritePropertyName("progressToken");
JsonSerializer.Serialize(writer, value.ProgressToken, options.GetTypeInfo(typeof(ProgressToken)));
writer.WriteNumber("progress", value.Progress.Progress);
if (value.Progress.Total is { } total)
{
writer.WriteNumber("total", total);
}
if (value.Progress.Message is { } message)
{
writer.WriteString("message", message);
}
writer.WriteEndObject();
}
}
}

View file

@ -0,0 +1,102 @@
using ModelContextProtocol.Utils;
using System.ComponentModel;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Represents a progress token, which can be either a string or an integer.
/// </summary>
[JsonConverter(typeof(Converter))]
public readonly struct ProgressToken : IEquatable<ProgressToken>
{
/// <summary>The id, either a string or a boxed long or null.</summary>
private readonly object? _id;
/// <summary>Initializes a new instance of the <see cref="ProgressToken"/> with a specified value.</summary>
/// <param name="value">The required ID value.</param>
public ProgressToken(string value)
{
Throw.IfNull(value);
_id = value;
}
/// <summary>Initializes a new instance of the <see cref="ProgressToken"/> with a specified value.</summary>
/// <param name="value">The required ID value.</param>
public ProgressToken(long value)
{
// Box the long. Progress tokens are almost always strings in practice, so this should be rare.
_id = value;
}
/// <summary>Gets whether the identifier is uninitialized.</summary>
public bool IsDefault => _id is null;
/// <inheritdoc />
public override string? ToString() =>
_id is string stringValue ? $"\"{stringValue}\"" :
_id is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) :
null;
/// <summary>
/// Compares this ProgressToken to another ProgressToken.
/// </summary>
public bool Equals(ProgressToken other) => Equals(_id, other._id);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is ProgressToken other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => _id?.GetHashCode() ?? 0;
/// <summary>
/// Compares two ProgressTokens for equality.
/// </summary>
public static bool operator ==(ProgressToken left, ProgressToken right) => left.Equals(right);
/// <summary>
/// Compares two ProgressTokens for inequality.
/// </summary>
public static bool operator !=(ProgressToken left, ProgressToken right) => !left.Equals(right);
/// <summary>
/// JSON converter for ProgressToken that handles both string and number values.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class Converter : JsonConverter<ProgressToken>
{
/// <inheritdoc />
public override ProgressToken Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.String => new(reader.GetString()!),
JsonTokenType.Number => new(reader.GetInt64()),
_ => throw new JsonException("progressToken must be a string or an integer"),
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, ProgressToken value, JsonSerializerOptions options)
{
Throw.IfNull(writer);
switch (value._id)
{
case string str:
writer.WriteStringValue(str);
return;
case long longValue:
writer.WriteNumberValue(longValue);
return;
case null:
writer.WriteStringValue(string.Empty);
return;
}
}
}
}

View file

@ -1,76 +1,55 @@
using System.Text.Json.Serialization; using ModelContextProtocol.Utils;
using System.ComponentModel;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages; namespace ModelContextProtocol.Protocol.Messages;
/// <summary> /// <summary>
/// Represents a JSON-RPC request identifier which can be either a string or a number. /// Represents a JSON-RPC request identifier, which can be either a string or an integer.
/// </summary> /// </summary>
[JsonConverter(typeof(RequestIdConverter))] [JsonConverter(typeof(Converter))]
public readonly struct RequestId : IEquatable<RequestId> public readonly struct RequestId : IEquatable<RequestId>
{ {
private readonly object _value; /// <summary>The id, either a string or a boxed long or null.</summary>
private readonly object? _id;
private RequestId(object value) /// <summary>Initializes a new instance of the <see cref="RequestId"/> with a specified value.</summary>
/// <param name="value">The required ID value.</param>
public RequestId(string value)
{ {
_value = value; Throw.IfNull(value);
_id = value;
} }
/// <summary> /// <summary>Initializes a new instance of the <see cref="RequestId"/> with a specified value.</summary>
/// Creates a new RequestId from a string. /// <param name="value">The required ID value.</param>
/// </summary> public RequestId(long value)
/// <param name="value">The Id</param> {
/// <returns>Wrapped Id object</returns> // Box the long. Request IDs are almost always strings in practice, so this should be rare.
public static RequestId FromString(string value) => new(value); _id = value;
}
/// <summary> /// <summary>Gets whether the identifier is uninitialized.</summary>
/// Creates a new RequestId from a number. public bool IsDefault => _id is null;
/// </summary>
/// <param name="value">The Id</param>
/// <returns>Wrapped Id object</returns>
public static RequestId FromNumber(long value) => new(value);
/// <summary> /// <inheritdoc />
/// Checks if the RequestId is a string. public override string ToString() =>
/// </summary> _id is string stringValue ? $"\"{stringValue}\"" :
public bool IsString => _value is string; _id is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) :
string.Empty;
/// <summary>
/// Checks if the RequestId is a number.
/// </summary>
public bool IsNumber => _value is long;
/// <summary>
/// Checks if the request id is valid (has a value)
/// </summary>
public bool IsValid => _value != null;
/// <summary>
/// Gets the RequestId as a string.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the RequestId is not a string</exception>"
public string AsString => _value as string ?? throw new InvalidOperationException("RequestId is not a string");
/// <summary>
/// Gets the RequestId as a number.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the RequestId is not a number</exception>""
public long AsNumber => _value is long number ? number : throw new InvalidOperationException("RequestId is not a number");
/// <summary>
/// Returns the string representation of the RequestId. Will box the value if it is a number.
/// </summary>
public override string ToString() => _value.ToString() ?? "";
/// <summary> /// <summary>
/// Compares this RequestId to another RequestId. /// Compares this RequestId to another RequestId.
/// </summary> /// </summary>
public bool Equals(RequestId other) => _value.Equals(other._value); public bool Equals(RequestId other) => Equals(_id, other._id);
/// <inheritdoc /> /// <inheritdoc />
public override bool Equals(object? obj) => obj is RequestId other && Equals(other); public override bool Equals(object? obj) => obj is RequestId other && Equals(other);
/// <inheritdoc /> /// <inheritdoc />
public override int GetHashCode() => _value.GetHashCode(); public override int GetHashCode() => _id?.GetHashCode() ?? 0;
/// <summary> /// <summary>
/// Compares two RequestIds for equality. /// Compares two RequestIds for equality.
@ -81,4 +60,43 @@ public readonly struct RequestId : IEquatable<RequestId>
/// Compares two RequestIds for inequality. /// Compares two RequestIds for inequality.
/// </summary> /// </summary>
public static bool operator !=(RequestId left, RequestId right) => !left.Equals(right); public static bool operator !=(RequestId left, RequestId right) => !left.Equals(right);
/// <summary>
/// JSON converter for RequestId that handles both string and number values.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class Converter: JsonConverter<RequestId>
{
/// <inheritdoc />
public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.String => new(reader.GetString()!),
JsonTokenType.Number => new(reader.GetInt64()),
_ => throw new JsonException("requestId must be a string or an integer"),
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerializerOptions options)
{
Throw.IfNull(writer);
switch (value._id)
{
case string str:
writer.WriteStringValue(str);
return;
case long longValue:
writer.WriteNumberValue(longValue);
return;
case null:
writer.WriteStringValue(string.Empty);
return;
}
}
}
} }

View file

@ -1,37 +0,0 @@
using ModelContextProtocol.Utils;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// JSON converter for RequestId that handles both string and number values.
/// </summary>
public class RequestIdConverter : JsonConverter<RequestId>
{
/// <inheritdoc />
public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.String => RequestId.FromString(reader.GetString()!),
JsonTokenType.Number => RequestId.FromNumber(reader.GetInt64()),
_ => throw new JsonException("RequestId must be either a string or a number"),
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerializerOptions options)
{
Throw.IfNull(writer);
if (value.IsString)
{
writer.WriteStringValue(value.AsString);
}
else
{
writer.WriteNumberValue(value.AsNumber);
}
}
}

View file

@ -0,0 +1,82 @@
namespace ModelContextProtocol.Protocol.Messages;
/// <summary>
/// Provides names for request methods used in the Model Context Protocol (MCP).
/// </summary>
public static class RequestMethods
{
/// <summary>
/// Sent from the client to request a list of tools the server has.
/// </summary>
public const string ToolsList = "tools/list";
/// <summary>
/// Used by the client to invoke a tool provided by the server.
/// </summary>
public const string ToolsCall = "tools/call";
/// <summary>
/// Sent from the client to request a list of prompts and prompt templates the server has.
/// </summary>
public const string PromptsList = "prompts/list";
/// <summary>
/// Used by the client to get a prompt provided by the server.
/// </summary>
public const string PromptsGet = "prompts/get";
/// <summary>
/// Sent from the client to request a list of resources the server has.
/// </summary>
public const string ResourcesList = "resources/list";
/// <summary>
/// Sent from the client to the server, to read a specific resource URI.
/// </summary>
public const string ResourcesRead = "resources/read";
/// <summary>
/// Sent from the client to request a list of resource templates the server has.
/// </summary>
public const string ResourcesTemplatesList = "resources/templates/list";
/// <summary>
/// Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.
/// </summary>
public const string ResourcesSubscribe = "resources/subscribe";
/// <summary>
/// Sent from the client to request cancellation of resources/updated notifications from the server.
/// </summary>
public const string ResourcesUnsubscribe = "resources/unsubscribe";
/// <summary>
/// Sent from the server to request a list of root URIs from the client.
/// </summary>
public const string RootsList = "roots/list";
/// <summary>
/// A ping, issued by either the server or the client, to check that the other party is still alive.
/// </summary>
public const string Ping = "ping";
/// <summary>
/// A request from the client to the server, to enable or adjust logging.
/// </summary>
public const string LoggingSetLevel = "logging/setLevel";
/// <summary>
/// A request from the client to the server, to ask for completion options.
/// </summary>
public const string CompletionComplete = "completion/complete";
/// <summary>
/// A request from the server to sample an LLM via the client.
/// </summary>
public const string SamplingCreateMessage = "sampling/createMessage";
/// <summary>
/// This request is sent from the client to the server when it first connects, asking it to begin initialization.
/// </summary>
public const string Initialize = "initialize";
}

View file

@ -118,7 +118,7 @@ internal sealed class SseClientSessionTransport : TransportBase
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
// Check if the message was an initialize request // Check if the message was an initialize request
if (message is JsonRpcRequest request && request.Method == "initialize") if (message is JsonRpcRequest request && request.Method == RequestMethods.Initialize)
{ {
// If the response is not a JSON-RPC response, it is an SSE message // If the response is not a JSON-RPC response, it is an SSE message
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase)) if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))

View file

@ -21,6 +21,7 @@ internal sealed class StdioClientStreamTransport : TransportBase
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonOptions; private readonly JsonSerializerOptions _jsonOptions;
private readonly DataReceivedEventHandler _logProcessErrors; private readonly DataReceivedEventHandler _logProcessErrors;
private readonly SemaphoreSlim _sendLock = new(1, 1);
private Process? _process; private Process? _process;
private Task? _readTask; private Task? _readTask;
private CancellationTokenSource? _shutdownCts; private CancellationTokenSource? _shutdownCts;
@ -150,6 +151,8 @@ internal sealed class StdioClientStreamTransport : TransportBase
/// <inheritdoc/> /// <inheritdoc/>
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default) public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
{ {
using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false);
if (!IsConnected || _process?.HasExited == true) if (!IsConnected || _process?.HasExited == true)
{ {
_logger.TransportNotConnected(EndpointName); _logger.TransportNotConnected(EndpointName);

View file

@ -1,4 +1,6 @@
namespace ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Protocol.Messages;
namespace ModelContextProtocol.Protocol.Types;
/// <summary> /// <summary>
/// A request from the server to get a list of root URIs from the client. /// A request from the server to get a list of root URIs from the client.
@ -10,5 +12,5 @@ public class ListRootsRequestParams
/// Optional progress token for out-of-band progress notifications. /// Optional progress token for out-of-band progress notifications.
/// </summary> /// </summary>
[System.Text.Json.Serialization.JsonPropertyName("progressToken")] [System.Text.Json.Serialization.JsonPropertyName("progressToken")]
public string? ProgressToken { get; init; } public ProgressToken? ProgressToken { get; init; }
} }

View file

@ -1,3 +1,4 @@
using ModelContextProtocol.Protocol.Messages;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Types; namespace ModelContextProtocol.Protocol.Types;
@ -11,5 +12,5 @@ public class RequestParamsMetadata
/// If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. /// If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications.
/// </summary> /// </summary>
[JsonPropertyName("progressToken")] [JsonPropertyName("progressToken")]
public object ProgressToken { get; set; } = default!; public ProgressToken? ProgressToken { get; set; } = default!;
} }

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Shared;
using ModelContextProtocol.Utils; using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json; using ModelContextProtocol.Utils.Json;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
@ -98,6 +99,27 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
}; };
} }
if (pi.ParameterType == typeof(IProgress<ProgressNotificationValue>))
{
// Bind IProgress<ProgressNotificationValue> to the progress token in the request,
// if there is one. If we can't get one, return a nop progress.
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
{
var requestContent = GetRequestContext(args);
if (requestContent?.Server is { } server &&
requestContent?.Params?.Meta?.ProgressToken is { } progressToken)
{
return new TokenProgress(server, progressToken);
}
return NullProgress.Instance;
},
};
}
// We assume that if the services used to create the tool support a particular type, // We assume that if the services used to create the tool support a particular type,
// so too do the services associated with the server. This is the same basic assumption // so too do the services associated with the server. This is the same basic assumption
// made in ASP.NET. // made in ASP.NET.

View file

@ -78,7 +78,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
}); });
}; };
AddNotificationHandler("notifications/initialized", _ => AddNotificationHandler(NotificationMethods.InitializedNotification, _ =>
{ {
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools) if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
{ {
@ -176,13 +176,13 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
private void SetPingHandler() private void SetPingHandler()
{ {
SetRequestHandler<JsonNode, PingResult>("ping", SetRequestHandler<JsonNode, PingResult>(RequestMethods.Ping,
(request, _) => Task.FromResult(new PingResult())); (request, _) => Task.FromResult(new PingResult()));
} }
private void SetInitializeHandler(McpServerOptions options) private void SetInitializeHandler(McpServerOptions options)
{ {
SetRequestHandler<InitializeRequestParams, InitializeResult>("initialize", SetRequestHandler<InitializeRequestParams, InitializeResult>(RequestMethods.Initialize,
(request, _) => (request, _) =>
{ {
ClientCapabilities = request?.Capabilities ?? new(); ClientCapabilities = request?.Capabilities ?? new();
@ -205,7 +205,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
private void SetCompletionHandler(McpServerOptions options) private void SetCompletionHandler(McpServerOptions options)
{ {
// This capability is not optional, so return an empty result if there is no handler. // This capability is not optional, so return an empty result if there is no handler.
SetRequestHandler<CompleteRequestParams, CompleteResult>("completion/complete", SetRequestHandler<CompleteRequestParams, CompleteResult>(RequestMethods.CompletionComplete,
options.GetCompletionHandler is { } handler ? options.GetCompletionHandler is { } handler ?
(request, ct) => handler(new(this, request), ct) : (request, ct) => handler(new(this, request), ct) :
(request, ct) => Task.FromResult(new CompleteResult() { Completion = new() { Values = [], Total = 0, HasMore = false } })); (request, ct) => Task.FromResult(new CompleteResult() { Completion = new() { Values = [], Total = 0, HasMore = false } }));
@ -229,11 +229,11 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
listResourcesHandler ??= (static (_, _) => Task.FromResult(new ListResourcesResult())); listResourcesHandler ??= (static (_, _) => Task.FromResult(new ListResourcesResult()));
SetRequestHandler<ListResourcesRequestParams, ListResourcesResult>("resources/list", (request, ct) => listResourcesHandler(new(this, request), ct)); SetRequestHandler<ListResourcesRequestParams, ListResourcesResult>(RequestMethods.ResourcesList, (request, ct) => listResourcesHandler(new(this, request), ct));
SetRequestHandler<ReadResourceRequestParams, ReadResourceResult>("resources/read", (request, ct) => readResourceHandler(new(this, request), ct)); SetRequestHandler<ReadResourceRequestParams, ReadResourceResult>(RequestMethods.ResourcesRead, (request, ct) => readResourceHandler(new(this, request), ct));
listResourceTemplatesHandler ??= (static (_, _) => Task.FromResult(new ListResourceTemplatesResult())); listResourceTemplatesHandler ??= (static (_, _) => Task.FromResult(new ListResourceTemplatesResult()));
SetRequestHandler<ListResourceTemplatesRequestParams, ListResourceTemplatesResult>("resources/templates/list", (request, ct) => listResourceTemplatesHandler(new(this, request), ct)); SetRequestHandler<ListResourceTemplatesRequestParams, ListResourceTemplatesResult>(RequestMethods.ResourcesTemplatesList, (request, ct) => listResourceTemplatesHandler(new(this, request), ct));
if (resourcesCapability.Subscribe is not true) if (resourcesCapability.Subscribe is not true)
{ {
@ -247,8 +247,8 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
throw new McpServerException("Resources capability was enabled with subscribe support, but SubscribeToResources and/or UnsubscribeFromResources handlers were not specified."); throw new McpServerException("Resources capability was enabled with subscribe support, but SubscribeToResources and/or UnsubscribeFromResources handlers were not specified.");
} }
SetRequestHandler<SubscribeRequestParams, EmptyResult>("resources/subscribe", (request, ct) => subscribeHandler(new(this, request), ct)); SetRequestHandler<SubscribeRequestParams, EmptyResult>(RequestMethods.ResourcesSubscribe, (request, ct) => subscribeHandler(new(this, request), ct));
SetRequestHandler<UnsubscribeRequestParams, EmptyResult>("resources/unsubscribe", (request, ct) => unsubscribeHandler(new(this, request), ct)); SetRequestHandler<UnsubscribeRequestParams, EmptyResult>(RequestMethods.ResourcesUnsubscribe, (request, ct) => unsubscribeHandler(new(this, request), ct));
} }
private void SetPromptsHandler(McpServerOptions options) private void SetPromptsHandler(McpServerOptions options)
@ -264,8 +264,8 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
throw new McpServerException("Prompts capability was enabled, but ListPrompts and/or GetPrompt handlers were not specified."); throw new McpServerException("Prompts capability was enabled, but ListPrompts and/or GetPrompt handlers were not specified.");
} }
SetRequestHandler<ListPromptsRequestParams, ListPromptsResult>("prompts/list", (request, ct) => listPromptsHandler(new(this, request), ct)); SetRequestHandler<ListPromptsRequestParams, ListPromptsResult>(RequestMethods.PromptsList, (request, ct) => listPromptsHandler(new(this, request), ct));
SetRequestHandler<GetPromptRequestParams, GetPromptResult>("prompts/get", (request, ct) => getPromptHandler(new(this, request), ct)); SetRequestHandler<GetPromptRequestParams, GetPromptResult>(RequestMethods.PromptsGet, (request, ct) => getPromptHandler(new(this, request), ct));
} }
private void SetToolsHandler(McpServerOptions options) private void SetToolsHandler(McpServerOptions options)
@ -363,8 +363,8 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
} }
} }
SetRequestHandler<ListToolsRequestParams, ListToolsResult>("tools/list", (request, ct) => listToolsHandler(new(this, request), ct)); SetRequestHandler<ListToolsRequestParams, ListToolsResult>(RequestMethods.ToolsList, (request, ct) => listToolsHandler(new(this, request), ct));
SetRequestHandler<CallToolRequestParams, CallToolResponse>("tools/call", (request, ct) => callToolHandler(new(this, request), ct)); SetRequestHandler<CallToolRequestParams, CallToolResponse>(RequestMethods.ToolsCall, (request, ct) => callToolHandler(new(this, request), ct));
} }
private void SetSetLoggingLevelHandler(McpServerOptions options) private void SetSetLoggingLevelHandler(McpServerOptions options)
@ -379,6 +379,6 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
throw new McpServerException("Logging capability was enabled, but SetLoggingLevelHandler was not specified."); throw new McpServerException("Logging capability was enabled, but SetLoggingLevelHandler was not specified.");
} }
SetRequestHandler<SetLevelRequestParams, EmptyResult>("logging/setLevel", (request, ct) => setLoggingLevelHandler(new(this, request), ct)); SetRequestHandler<SetLevelRequestParams, EmptyResult>(RequestMethods.LoggingSetLevel, (request, ct) => setLoggingLevelHandler(new(this, request), ct));
} }
} }

View file

@ -26,7 +26,7 @@ public static class McpServerExtensions
} }
return server.SendRequestAsync<CreateMessageResult>( return server.SendRequestAsync<CreateMessageResult>(
new JsonRpcRequest { Method = "sampling/createMessage", Params = request }, new JsonRpcRequest { Method = RequestMethods.SamplingCreateMessage, Params = request },
cancellationToken); cancellationToken);
} }
@ -165,7 +165,7 @@ public static class McpServerExtensions
} }
return server.SendRequestAsync<ListRootsResult>( return server.SendRequestAsync<ListRootsResult>(
new JsonRpcRequest { Method = "roots/list", Params = request }, new JsonRpcRequest { Method = RequestMethods.RootsList, Params = request },
cancellationToken); cancellationToken);
} }

View file

@ -23,8 +23,9 @@ internal sealed class McpSession : IDisposable
private readonly ConcurrentDictionary<RequestId, TaskCompletionSource<IJsonRpcMessage>> _pendingRequests = []; private readonly ConcurrentDictionary<RequestId, TaskCompletionSource<IJsonRpcMessage>> _pendingRequests = [];
private readonly JsonSerializerOptions _jsonOptions; private readonly JsonSerializerOptions _jsonOptions;
private readonly ILogger _logger; private readonly ILogger _logger;
private int _nextRequestId; private readonly string _id = Guid.NewGuid().ToString("N");
private long _nextRequestId;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="McpSession"/> class. /// Initializes a new instance of the <see cref="McpSession"/> class.
@ -141,7 +142,7 @@ internal sealed class McpSession : IDisposable
private void HandleMessageWithId(IJsonRpcMessage message, IJsonRpcMessageWithId messageWithId) private void HandleMessageWithId(IJsonRpcMessage message, IJsonRpcMessageWithId messageWithId)
{ {
if (!messageWithId.Id.IsValid) if (messageWithId.Id.IsDefault)
{ {
_logger.RequestHasInvalidId(EndpointName); _logger.RequestHasInvalidId(EndpointName);
} }
@ -212,7 +213,10 @@ internal sealed class McpSession : IDisposable
} }
// Set request ID // Set request ID
request.Id = RequestId.FromNumber(Interlocked.Increment(ref _nextRequestId)); if (request.Id.IsDefault)
{
request.Id = new RequestId($"{_id}-{Interlocked.Increment(ref _nextRequestId)}");
}
var tcs = new TaskCompletionSource<IJsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously); var tcs = new TaskCompletionSource<IJsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests[request.Id] = tcs; _pendingRequests[request.Id] = tcs;

View file

@ -0,0 +1,31 @@
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Server;
using ModelContextProtocol.Shared;
namespace ModelContextProtocol;
/// <summary>
/// Provides an <see cref="IProgress{ProgressNotificationValue}"/> tied to a specific progress token and that will issue
/// progress notifications to the supplied endpoint.
/// </summary>
internal sealed class TokenProgress(IMcpServer server, ProgressToken progressToken) : IProgress<ProgressNotificationValue>
{
/// <inheritdoc />
public void Report(ProgressNotificationValue value)
{
_ = server.SendMessageAsync(new JsonRpcNotification()
{
Method = NotificationMethods.ProgressNotification,
Params = new ProgressNotification()
{
ProgressToken = progressToken,
Progress = new()
{
Progress = value.Progress,
Total = value.Total,
Message = value.Message,
},
},
}, CancellationToken.None);
}
}

Some files were not shown because too many files have changed in this diff Show more