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
11 changed files with 627 additions and 228 deletions

View file

@ -34,4 +34,27 @@ public static class NotificationMethods
/// Sent by the server when a log message is generated.
/// </summary>
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;
/// <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>
[JsonConverter(typeof(RequestIdConverter))]
[JsonConverter(typeof(Converter))]
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>
/// Creates a new RequestId from a string.
/// </summary>
/// <param name="value">The Id</param>
/// <returns>Wrapped Id object</returns>
public static RequestId FromString(string value) => new(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(long value)
{
// Box the long. Request IDs are almost always strings in practice, so this should be rare.
_id = value;
}
/// <summary>
/// Creates a new RequestId from a number.
/// </summary>
/// <param name="value">The Id</param>
/// <returns>Wrapped Id object</returns>
public static RequestId FromNumber(long value) => new(value);
/// <summary>Gets whether the identifier is uninitialized.</summary>
public bool IsDefault => _id is null;
/// <summary>
/// Checks if the RequestId is a string.
/// </summary>
public bool IsString => _value is string;
/// <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() ?? "";
/// <inheritdoc />
public override string ToString() =>
_id is string stringValue ? $"\"{stringValue}\"" :
_id is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) :
string.Empty;
/// <summary>
/// Compares this RequestId to another RequestId.
/// </summary>
public bool Equals(RequestId other) => _value.Equals(other._value);
public bool Equals(RequestId other) => Equals(_id, other._id);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is RequestId other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => _value.GetHashCode();
public override int GetHashCode() => _id?.GetHashCode() ?? 0;
/// <summary>
/// Compares two RequestIds for equality.
@ -81,4 +60,43 @@ public readonly struct RequestId : IEquatable<RequestId>
/// Compares two RequestIds for inequality.
/// </summary>
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);
// 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 (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))

View file

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

View file

@ -1,4 +1,6 @@
namespace ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Protocol.Messages;
namespace ModelContextProtocol.Protocol.Types;
/// <summary>
/// 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.
/// </summary>
[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;
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.
/// </summary>
[JsonPropertyName("progressToken")]
public object ProgressToken { get; set; } = default!;
public ProgressToken? ProgressToken { get; set; } = default!;
}