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

View file

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

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Shared;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
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,
// so too do the services associated with the server. This is the same basic assumption
// 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)
{
@ -176,13 +176,13 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
private void SetPingHandler()
{
SetRequestHandler<JsonNode, PingResult>("ping",
SetRequestHandler<JsonNode, PingResult>(RequestMethods.Ping,
(request, _) => Task.FromResult(new PingResult()));
}
private void SetInitializeHandler(McpServerOptions options)
{
SetRequestHandler<InitializeRequestParams, InitializeResult>("initialize",
SetRequestHandler<InitializeRequestParams, InitializeResult>(RequestMethods.Initialize,
(request, _) =>
{
ClientCapabilities = request?.Capabilities ?? new();
@ -205,7 +205,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
private void SetCompletionHandler(McpServerOptions options)
{
// 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 ?
(request, ct) => handler(new(this, request), ct) :
(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()));
SetRequestHandler<ListResourcesRequestParams, ListResourcesResult>("resources/list", (request, ct) => listResourcesHandler(new(this, request), ct));
SetRequestHandler<ReadResourceRequestParams, ReadResourceResult>("resources/read", (request, ct) => readResourceHandler(new(this, request), ct));
SetRequestHandler<ListResourcesRequestParams, ListResourcesResult>(RequestMethods.ResourcesList, (request, ct) => listResourcesHandler(new(this, request), ct));
SetRequestHandler<ReadResourceRequestParams, ReadResourceResult>(RequestMethods.ResourcesRead, (request, ct) => readResourceHandler(new(this, request), ct));
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)
{
@ -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.");
}
SetRequestHandler<SubscribeRequestParams, EmptyResult>("resources/subscribe", (request, ct) => subscribeHandler(new(this, request), ct));
SetRequestHandler<UnsubscribeRequestParams, EmptyResult>("resources/unsubscribe", (request, ct) => unsubscribeHandler(new(this, request), ct));
SetRequestHandler<SubscribeRequestParams, EmptyResult>(RequestMethods.ResourcesSubscribe, (request, ct) => subscribeHandler(new(this, request), ct));
SetRequestHandler<UnsubscribeRequestParams, EmptyResult>(RequestMethods.ResourcesUnsubscribe, (request, ct) => unsubscribeHandler(new(this, request), ct));
}
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.");
}
SetRequestHandler<ListPromptsRequestParams, ListPromptsResult>("prompts/list", (request, ct) => listPromptsHandler(new(this, request), ct));
SetRequestHandler<GetPromptRequestParams, GetPromptResult>("prompts/get", (request, ct) => getPromptHandler(new(this, request), ct));
SetRequestHandler<ListPromptsRequestParams, ListPromptsResult>(RequestMethods.PromptsList, (request, ct) => listPromptsHandler(new(this, request), ct));
SetRequestHandler<GetPromptRequestParams, GetPromptResult>(RequestMethods.PromptsGet, (request, ct) => getPromptHandler(new(this, request), ct));
}
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<CallToolRequestParams, CallToolResponse>("tools/call", (request, ct) => callToolHandler(new(this, request), ct));
SetRequestHandler<ListToolsRequestParams, ListToolsResult>(RequestMethods.ToolsList, (request, ct) => listToolsHandler(new(this, request), ct));
SetRequestHandler<CallToolRequestParams, CallToolResponse>(RequestMethods.ToolsCall, (request, ct) => callToolHandler(new(this, request), ct));
}
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.");
}
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>(
new JsonRpcRequest { Method = "sampling/createMessage", Params = request },
new JsonRpcRequest { Method = RequestMethods.SamplingCreateMessage, Params = request },
cancellationToken);
}
@ -165,7 +165,7 @@ public static class McpServerExtensions
}
return server.SendRequestAsync<ListRootsResult>(
new JsonRpcRequest { Method = "roots/list", Params = request },
new JsonRpcRequest { Method = RequestMethods.RootsList, Params = request },
cancellationToken);
}

View file

@ -23,8 +23,9 @@ internal sealed class McpSession : IDisposable
private readonly ConcurrentDictionary<RequestId, TaskCompletionSource<IJsonRpcMessage>> _pendingRequests = [];
private readonly JsonSerializerOptions _jsonOptions;
private readonly ILogger _logger;
private int _nextRequestId;
private readonly string _id = Guid.NewGuid().ToString("N");
private long _nextRequestId;
/// <summary>
/// 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)
{
if (!messageWithId.Id.IsValid)
if (messageWithId.Id.IsDefault)
{
_logger.RequestHasInvalidId(EndpointName);
}
@ -212,7 +213,10 @@ internal sealed class McpSession : IDisposable
}
// 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);
_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