Add initial Activity / Metric support (#183)
This commit is contained in:
parent
674cb1515c
commit
4b38d3e71b
13 changed files with 443 additions and 61 deletions
|
|
@ -33,6 +33,8 @@
|
|||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="$(MicrosoftExtensionsVersion)" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.11.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.11.2" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
|
|
|
|||
29
src/ModelContextProtocol/Diagnostics.cs
Normal file
29
src/ModelContextProtocol/Diagnostics.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace ModelContextProtocol;
|
||||
|
||||
internal static class Diagnostics
|
||||
{
|
||||
internal static ActivitySource ActivitySource { get; } = new("ModelContextProtocol");
|
||||
|
||||
/// <summary>
|
||||
/// Follows boundaries from http.server.request.duration/http.client.request.duration
|
||||
/// </summary>
|
||||
internal static InstrumentAdvice<double> ShortSecondsBucketBoundaries { get; } = new()
|
||||
{
|
||||
HistogramBucketBoundaries = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Not based on a standard. Larger bucket sizes for longer lasting operations, e.g. HTTP connection duration.
|
||||
/// See https://github.com/open-telemetry/semantic-conventions/issues/336
|
||||
/// </summary>
|
||||
internal static InstrumentAdvice<double> LongSecondsBucketBoundaries { get; } = new()
|
||||
{
|
||||
HistogramBucketBoundaries = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300],
|
||||
};
|
||||
|
||||
internal static Meter Meter { get; } = new("ModelContextProtocol");
|
||||
|
||||
}
|
||||
|
|
@ -12,15 +12,15 @@ namespace ModelContextProtocol.Protocol.Messages;
|
|||
[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>The token, either a string or a boxed long or null.</summary>
|
||||
private readonly object? _token;
|
||||
|
||||
/// <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;
|
||||
_token = value;
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ProgressToken"/> with a specified value.</summary>
|
||||
|
|
@ -28,28 +28,29 @@ public readonly struct ProgressToken : IEquatable<ProgressToken>
|
|||
public ProgressToken(long value)
|
||||
{
|
||||
// Box the long. Progress tokens are almost always strings in practice, so this should be rare.
|
||||
_id = value;
|
||||
_token = value;
|
||||
}
|
||||
|
||||
/// <summary>Gets whether the identifier is uninitialized.</summary>
|
||||
public bool IsDefault => _id is null;
|
||||
/// <summary>Gets the underlying object for this token.</summary>
|
||||
/// <remarks>This will either be a <see cref="string"/>, a boxed <see cref="long"/>, or <see langword="null"/>.</remarks>
|
||||
public object? Token => _token;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string? ToString() =>
|
||||
_id is string stringValue ? $"\"{stringValue}\"" :
|
||||
_id is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) :
|
||||
_token is string stringValue ? $"{stringValue}" :
|
||||
_token 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);
|
||||
public bool Equals(ProgressToken other) => Equals(_token, other._token);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is ProgressToken other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => _id?.GetHashCode() ?? 0;
|
||||
public override int GetHashCode() => _token?.GetHashCode() ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two ProgressTokens for equality.
|
||||
|
|
@ -83,7 +84,7 @@ public readonly struct ProgressToken : IEquatable<ProgressToken>
|
|||
{
|
||||
Throw.IfNull(writer);
|
||||
|
||||
switch (value._id)
|
||||
switch (value._token)
|
||||
{
|
||||
case string str:
|
||||
writer.WriteStringValue(str);
|
||||
|
|
|
|||
|
|
@ -31,12 +31,13 @@ public readonly struct RequestId : IEquatable<RequestId>
|
|||
_id = value;
|
||||
}
|
||||
|
||||
/// <summary>Gets whether the identifier is uninitialized.</summary>
|
||||
public bool IsDefault => _id is null;
|
||||
/// <summary>Gets the underlying object for this id.</summary>
|
||||
/// <remarks>This will either be a <see cref="string"/>, a boxed <see cref="long"/>, or <see langword="null"/>.</remarks>
|
||||
public object? Id => _id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() =>
|
||||
_id is string stringValue ? $"\"{stringValue}\"" :
|
||||
_id is string stringValue ? stringValue :
|
||||
_id is long longValue ? longValue.ToString(CultureInfo.InvariantCulture) :
|
||||
string.Empty;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||
using ModelContextProtocol.Logging;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Utils;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
|
|||
protected void StartSession(ITransport sessionTransport)
|
||||
{
|
||||
_sessionCts = new CancellationTokenSource();
|
||||
_session = new McpSession(sessionTransport, EndpointName, _requestHandlers, _notificationHandlers, _logger);
|
||||
_session = new McpSession(this is IMcpServer, sessionTransport, EndpointName, _requestHandlers, _notificationHandlers, _logger);
|
||||
MessageProcessingTask = _session.ProcessMessagesAsync(_sessionCts.Token);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ using ModelContextProtocol.Client;
|
|||
using ModelContextProtocol.Logging;
|
||||
using ModelContextProtocol.Protocol.Messages;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Utils;
|
||||
using ModelContextProtocol.Utils.Json;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Shared;
|
||||
|
|
@ -16,9 +19,22 @@ namespace ModelContextProtocol.Shared;
|
|||
/// </summary>
|
||||
internal sealed class McpSession : IDisposable
|
||||
{
|
||||
private static readonly Histogram<double> s_clientSessionDuration = Diagnostics.Meter.CreateHistogram<double>(
|
||||
"mcp.client.session.duration", "s", "Measures the duration of a client session.", advice: Diagnostics.LongSecondsBucketBoundaries);
|
||||
private static readonly Histogram<double> s_serverSessionDuration = Diagnostics.Meter.CreateHistogram<double>(
|
||||
"mcp.server.session.duration", "s", "Measures the duration of a server session.", advice: Diagnostics.LongSecondsBucketBoundaries);
|
||||
|
||||
private static readonly Histogram<double> s_serverRequestDuration = Diagnostics.Meter.CreateHistogram<double>(
|
||||
"rpc.server.duration", "s", "Measures the duration of inbound RPC.", advice: Diagnostics.ShortSecondsBucketBoundaries);
|
||||
private static readonly Histogram<double> s_clientRequestDuration = Diagnostics.Meter.CreateHistogram<double>(
|
||||
"rpc.client.duration", "s", "Measures the duration of outbound RPC.", advice: Diagnostics.ShortSecondsBucketBoundaries);
|
||||
|
||||
private readonly bool _isServer;
|
||||
private readonly string _transportKind;
|
||||
private readonly ITransport _transport;
|
||||
private readonly RequestHandlers _requestHandlers;
|
||||
private readonly NotificationHandlers _notificationHandlers;
|
||||
private readonly long _sessionStartingTimestamp = Stopwatch.GetTimestamp();
|
||||
|
||||
/// <summary>Collection of requests sent on this session and waiting for responses.</summary>
|
||||
private readonly ConcurrentDictionary<RequestId, TaskCompletionSource<IJsonRpcMessage>> _pendingRequests = [];
|
||||
|
|
@ -36,12 +52,14 @@ internal sealed class McpSession : IDisposable
|
|||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpSession"/> class.
|
||||
/// </summary>
|
||||
/// <param name="isServer">true if this is a server; false if it's a client.</param>
|
||||
/// <param name="transport">An MCP transport implementation.</param>
|
||||
/// <param name="endpointName">The name of the endpoint for logging and debug purposes.</param>
|
||||
/// <param name="requestHandlers">A collection of request handlers.</param>
|
||||
/// <param name="notificationHandlers">A collection of notification handlers.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public McpSession(
|
||||
bool isServer,
|
||||
ITransport transport,
|
||||
string endpointName,
|
||||
RequestHandlers requestHandlers,
|
||||
|
|
@ -50,6 +68,15 @@ internal sealed class McpSession : IDisposable
|
|||
{
|
||||
Throw.IfNull(transport);
|
||||
|
||||
_transportKind = transport switch
|
||||
{
|
||||
StdioClientSessionTransport or StdioServerTransport => "stdio",
|
||||
StreamClientSessionTransport or StreamServerTransport => "stream",
|
||||
SseClientSessionTransport or SseResponseStreamTransport => "sse",
|
||||
_ => "unknownTransport"
|
||||
};
|
||||
|
||||
_isServer = isServer;
|
||||
_transport = transport;
|
||||
EndpointName = endpointName;
|
||||
_requestHandlers = requestHandlers;
|
||||
|
|
@ -121,7 +148,7 @@ internal sealed class McpSession : IDisposable
|
|||
JsonRpc = "2.0",
|
||||
Error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = ErrorCodes.InternalError,
|
||||
Code = (ex as McpServerException)?.ErrorCode ?? ErrorCodes.InternalError,
|
||||
Message = ex.Message
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
|
@ -152,23 +179,55 @@ internal sealed class McpSession : IDisposable
|
|||
|
||||
private async Task HandleMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (message)
|
||||
Histogram<double> durationMetric = _isServer ? s_serverRequestDuration : s_clientRequestDuration;
|
||||
string method = GetMethodName(message);
|
||||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
Activity? activity = Diagnostics.ActivitySource.HasListeners() ?
|
||||
Diagnostics.ActivitySource.StartActivity(CreateActivityName(method)) :
|
||||
null;
|
||||
|
||||
TagList tags = default;
|
||||
bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null;
|
||||
try
|
||||
{
|
||||
case JsonRpcRequest request:
|
||||
await HandleRequest(request, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
if (addTags)
|
||||
{
|
||||
AddStandardTags(ref tags, method);
|
||||
}
|
||||
|
||||
case IJsonRpcMessageWithId messageWithId:
|
||||
HandleMessageWithId(message, messageWithId);
|
||||
break;
|
||||
switch (message)
|
||||
{
|
||||
case JsonRpcRequest request:
|
||||
if (addTags)
|
||||
{
|
||||
AddRpcRequestTags(ref tags, activity, request);
|
||||
}
|
||||
|
||||
case JsonRpcNotification notification:
|
||||
await HandleNotification(notification).ConfigureAwait(false);
|
||||
break;
|
||||
await HandleRequest(request, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.EndpointHandlerUnexpectedMessageType(EndpointName, message.GetType().Name);
|
||||
break;
|
||||
case JsonRpcNotification notification:
|
||||
await HandleNotification(notification).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case IJsonRpcMessageWithId messageWithId:
|
||||
HandleMessageWithId(message, messageWithId);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.EndpointHandlerUnexpectedMessageType(EndpointName, message.GetType().Name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (addTags)
|
||||
{
|
||||
AddExceptionTags(ref tags, e);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,7 +271,7 @@ internal sealed class McpSession : IDisposable
|
|||
|
||||
private void HandleMessageWithId(IJsonRpcMessage message, IJsonRpcMessageWithId messageWithId)
|
||||
{
|
||||
if (messageWithId.Id.IsDefault)
|
||||
if (messageWithId.Id.Id is null)
|
||||
{
|
||||
_logger.RequestHasInvalidId(EndpointName);
|
||||
}
|
||||
|
|
@ -229,22 +288,21 @@ internal sealed class McpSession : IDisposable
|
|||
|
||||
private async Task HandleRequest(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_requestHandlers.TryGetValue(request.Method, out var handler))
|
||||
{
|
||||
_logger.RequestHandlerCalled(EndpointName, request.Method);
|
||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
_logger.RequestHandlerCompleted(EndpointName, request.Method);
|
||||
await _transport.SendMessageAsync(new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
JsonRpc = "2.0",
|
||||
Result = result
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
if (!_requestHandlers.TryGetValue(request.Method, out var handler))
|
||||
{
|
||||
_logger.NoHandlerFoundForRequest(EndpointName, request.Method);
|
||||
throw new McpServerException("The method does not exist or is not available.", ErrorCodes.MethodNotFound);
|
||||
}
|
||||
|
||||
_logger.RequestHandlerCalled(EndpointName, request.Method);
|
||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
_logger.RequestHandlerCompleted(EndpointName, request.Method);
|
||||
await _transport.SendMessageAsync(new JsonRpcResponse
|
||||
{
|
||||
Id = request.Id,
|
||||
JsonRpc = "2.0",
|
||||
Result = result
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -264,17 +322,33 @@ internal sealed class McpSession : IDisposable
|
|||
throw new McpClientException("Transport is not connected");
|
||||
}
|
||||
|
||||
Histogram<double> durationMetric = _isServer ? s_serverRequestDuration : s_clientRequestDuration;
|
||||
string method = request.Method;
|
||||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
using Activity? activity = Diagnostics.ActivitySource.HasListeners() ?
|
||||
Diagnostics.ActivitySource.StartActivity(CreateActivityName(method)) :
|
||||
null;
|
||||
|
||||
// Set request ID
|
||||
if (request.Id.IsDefault)
|
||||
if (request.Id.Id is null)
|
||||
{
|
||||
request.Id = new RequestId($"{_id}-{Interlocked.Increment(ref _nextRequestId)}");
|
||||
}
|
||||
|
||||
TagList tags = default;
|
||||
bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null;
|
||||
|
||||
var tcs = new TaskCompletionSource<IJsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pendingRequests[request.Id] = tcs;
|
||||
|
||||
try
|
||||
{
|
||||
if (addTags)
|
||||
{
|
||||
AddStandardTags(ref tags, method);
|
||||
AddRpcRequestTags(ref tags, activity, request);
|
||||
}
|
||||
|
||||
// Expensive logging, use the logging framework to check if the logger is enabled
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
|
|
@ -319,9 +393,15 @@ internal sealed class McpSession : IDisposable
|
|||
_logger.RequestInvalidResponseType(EndpointName, request.Method);
|
||||
throw new McpClientException("Invalid response type");
|
||||
}
|
||||
catch (Exception ex) when (addTags)
|
||||
{
|
||||
AddExceptionTags(ref tags, ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingRequests.TryRemove(request.Id, out _);
|
||||
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -335,21 +415,49 @@ internal sealed class McpSession : IDisposable
|
|||
throw new McpClientException("Transport is not connected");
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
Histogram<double> durationMetric = _isServer ? s_serverRequestDuration : s_clientRequestDuration;
|
||||
string method = GetMethodName(message);
|
||||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
using Activity? activity = Diagnostics.ActivitySource.HasListeners() ?
|
||||
Diagnostics.ActivitySource.StartActivity(CreateActivityName(method)) :
|
||||
null;
|
||||
|
||||
TagList tags = default;
|
||||
bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.SendingMessage(EndpointName, JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>()));
|
||||
if (addTags)
|
||||
{
|
||||
AddStandardTags(ref tags, method);
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.SendingMessage(EndpointName, JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>()));
|
||||
}
|
||||
|
||||
await _transport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If the sent notification was a cancellation notification, cancel the pending request's await, as either the
|
||||
// server won't be sending a response, or per the specification, the response should be ignored. There are inherent
|
||||
// race conditions here, so it's possible and allowed for the operation to complete before we get to this point.
|
||||
if (message is JsonRpcNotification { Method: NotificationMethods.CancelledNotification } notification &&
|
||||
GetCancelledNotificationParams(notification.Params) is CancelledNotification cn &&
|
||||
_pendingRequests.TryRemove(cn.RequestId, out var tcs))
|
||||
{
|
||||
tcs.TrySetCanceled(default);
|
||||
}
|
||||
}
|
||||
|
||||
await _transport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If the sent notification was a cancellation notification, cancel the pending request's await, as either the
|
||||
// server won't be sending a response, or per the specification, the response should be ignored. There are inherent
|
||||
// race conditions here, so it's possible and allowed for the operation to complete before we get to this point.
|
||||
if (message is JsonRpcNotification { Method: NotificationMethods.CancelledNotification } notification &&
|
||||
GetCancelledNotificationParams(notification.Params) is CancelledNotification cn &&
|
||||
_pendingRequests.TryRemove(cn.RequestId, out var tcs))
|
||||
catch (Exception ex) when (addTags)
|
||||
{
|
||||
tcs.TrySetCanceled(default);
|
||||
AddExceptionTags(ref tags, ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -380,13 +488,127 @@ internal sealed class McpSession : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private string CreateActivityName(string method) =>
|
||||
$"mcp.{(_isServer ? "server" : "client")}.{_transportKind}/{method}";
|
||||
|
||||
private static string GetMethodName(IJsonRpcMessage message) =>
|
||||
message switch
|
||||
{
|
||||
JsonRpcRequest request => request.Method,
|
||||
JsonRpcNotification notification => notification.Method,
|
||||
_ => "unknownMethod",
|
||||
};
|
||||
|
||||
private void AddStandardTags(ref TagList tags, string method)
|
||||
{
|
||||
tags.Add("session.id", _id);
|
||||
tags.Add("rpc.system", "jsonrpc");
|
||||
tags.Add("rpc.jsonrpc.version", "2.0");
|
||||
tags.Add("rpc.method", method);
|
||||
tags.Add("network.transport", _transportKind);
|
||||
|
||||
// RPC spans convention also includes:
|
||||
// server.address, server.port, client.address, client.port, network.peer.address, network.peer.port, network.type
|
||||
}
|
||||
|
||||
private static void AddRpcRequestTags(ref TagList tags, Activity? activity, JsonRpcRequest request)
|
||||
{
|
||||
tags.Add("rpc.jsonrpc.request_id", request.Id.ToString());
|
||||
|
||||
if (request.Params is JsonElement je)
|
||||
{
|
||||
switch (request.Method)
|
||||
{
|
||||
case RequestMethods.ToolsCall:
|
||||
case RequestMethods.PromptsGet:
|
||||
if (je.TryGetProperty("name", out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
string name = prop.GetString()!;
|
||||
tags.Add("mcp.request.params.name", name);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.DisplayName = $"{request.Method}({name})";
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case RequestMethods.ResourcesRead:
|
||||
if (je.TryGetProperty("uri", out prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
string uri = prop.GetString()!;
|
||||
tags.Add("mcp.request.params.uri", uri);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.DisplayName = $"{request.Method}({uri})";
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddExceptionTags(ref TagList tags, Exception e)
|
||||
{
|
||||
tags.Add("error.type", e.GetType().FullName);
|
||||
tags.Add("rpc.jsonrpc.error_code",
|
||||
(e as McpClientException)?.ErrorCode is int clientError ? clientError :
|
||||
(e as McpServerException)?.ErrorCode is int serverError ? serverError :
|
||||
e is JsonException ? ErrorCodes.ParseError :
|
||||
ErrorCodes.InternalError);
|
||||
}
|
||||
|
||||
private static void FinalizeDiagnostics(
|
||||
Activity? activity, long? startingTimestamp, Histogram<double> durationMetric, ref TagList tags)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (startingTimestamp is not null)
|
||||
{
|
||||
durationMetric.Record(GetElapsed(startingTimestamp.Value).TotalSeconds, tags);
|
||||
}
|
||||
|
||||
if (activity is { IsAllDataRequested: true })
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
activity.AddTag(tag.Key, tag.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
activity?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Histogram<double> durationMetric = _isServer ? s_serverSessionDuration : s_clientSessionDuration;
|
||||
if (durationMetric.Enabled)
|
||||
{
|
||||
TagList tags = default;
|
||||
tags.Add("session.id", _id);
|
||||
tags.Add("network.transport", _transportKind);
|
||||
durationMetric.Record(GetElapsed(_sessionStartingTimestamp).TotalSeconds, tags);
|
||||
}
|
||||
|
||||
// Complete all pending requests with cancellation
|
||||
foreach (var entry in _pendingRequests)
|
||||
{
|
||||
entry.Value.TrySetCanceled();
|
||||
}
|
||||
|
||||
_pendingRequests.Clear();
|
||||
}
|
||||
|
||||
#if !NET
|
||||
private static readonly double s_timestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency;
|
||||
#endif
|
||||
|
||||
private static TimeSpan GetElapsed(long startingTimestamp) =>
|
||||
#if NET
|
||||
Stopwatch.GetElapsedTime(startingTimestamp);
|
||||
#else
|
||||
new((long)(s_timestampToTicks * (Stopwatch.GetTimestamp() - startingTimestamp)));
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -640,7 +640,7 @@ public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
|
|||
Assert.Equal(10, array.Length);
|
||||
for (int i = 0; i < array.Length; i++)
|
||||
{
|
||||
Assert.Equal("\"abc123\"", array[i].ProgressToken.ToString());
|
||||
Assert.Equal("abc123", array[i].ProgressToken.ToString());
|
||||
Assert.Equal(i, array[i].Progress.Progress);
|
||||
Assert.Equal(10, array[i].Progress.Total);
|
||||
Assert.Equal($"Progress {i}", array[i].Progress.Message);
|
||||
|
|
|
|||
79
tests/ModelContextProtocol.Tests/DiagnosticTests.cs
Normal file
79
tests/ModelContextProtocol.Tests/DiagnosticTests.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol.Transport;
|
||||
using ModelContextProtocol.Protocol.Types;
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenTelemetry.Trace;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipelines;
|
||||
|
||||
namespace ModelContextProtocol.Tests;
|
||||
|
||||
[Collection(nameof(DisableParallelization))]
|
||||
public class DiagnosticTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Session_TracksActivities()
|
||||
{
|
||||
var activities = new List<Activity>();
|
||||
|
||||
using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource("ModelContextProtocol")
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build())
|
||||
{
|
||||
await RunConnected(async (client, server) =>
|
||||
{
|
||||
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(tools);
|
||||
Assert.NotEmpty(tools);
|
||||
|
||||
var tool = tools.First(t => t.Name == "DoubleValue");
|
||||
await tool.InvokeAsync(new Dictionary<string, object?>() { ["amount"] = 42 }, TestContext.Current.CancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
Assert.NotEmpty(activities);
|
||||
|
||||
Activity toolCallActivity = activities.First(a =>
|
||||
a.Tags.Any(t => t.Key == "rpc.method" && t.Value == "tools/call"));
|
||||
Assert.Equal("DoubleValue", toolCallActivity.Tags.First(t => t.Key == "mcp.request.params.name").Value);
|
||||
}
|
||||
|
||||
private static async Task RunConnected(Func<IMcpClient, IMcpServer, Task> action)
|
||||
{
|
||||
Pipe clientToServerPipe = new(), serverToClientPipe = new();
|
||||
StreamServerTransport serverTransport = new(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream());
|
||||
StreamClientTransport clientTransport = new(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream());
|
||||
|
||||
Task serverTask;
|
||||
|
||||
await using (IMcpServer server = McpServerFactory.Create(serverTransport, new()
|
||||
{
|
||||
ServerInfo = new Implementation { Name = "TestServer", Version = "1.0.0" },
|
||||
Capabilities = new()
|
||||
{
|
||||
Tools = new()
|
||||
{
|
||||
ToolCollection = [McpServerTool.Create((int amount) => amount * 2, new() { Name = "DoubleValue", Description = "Doubles the value." })],
|
||||
}
|
||||
}
|
||||
}))
|
||||
{
|
||||
serverTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using (IMcpClient client = await McpClientFactory.CreateAsync(new()
|
||||
{
|
||||
Id = "TestServer",
|
||||
Name = "TestServer",
|
||||
TransportType = TransportTypes.StdIo,
|
||||
},
|
||||
createTransportFunc: (_, __) => clientTransport,
|
||||
cancellationToken: TestContext.Current.CancellationToken))
|
||||
{
|
||||
await action(client, server);
|
||||
}
|
||||
}
|
||||
|
||||
await serverTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
|
|
|
|||
38
tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs
Normal file
38
tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using ModelContextProtocol.Protocol.Messages;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
public class RequestIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void StringCtor_Roundtrips()
|
||||
{
|
||||
RequestId id = new("test-id");
|
||||
Assert.Equal("test-id", id.ToString());
|
||||
Assert.Equal("\"test-id\"", JsonSerializer.Serialize(id));
|
||||
Assert.Same("test-id", id.Id);
|
||||
|
||||
Assert.True(id.Equals(new("test-id")));
|
||||
Assert.False(id.Equals(new("tEst-id")));
|
||||
Assert.Equal("test-id".GetHashCode(), id.GetHashCode());
|
||||
|
||||
Assert.Equal(id, JsonSerializer.Deserialize<RequestId>(JsonSerializer.Serialize(id)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int64Ctor_Roundtrips()
|
||||
{
|
||||
RequestId id = new(42);
|
||||
Assert.Equal("42", id.ToString());
|
||||
Assert.Equal("42", JsonSerializer.Serialize(id));
|
||||
Assert.Equal(42, Assert.IsType<long>(id.Id));
|
||||
|
||||
Assert.True(id.Equals(new(42)));
|
||||
Assert.False(id.Equals(new(43)));
|
||||
Assert.False(id.Equals(new("42")));
|
||||
Assert.Equal(42L.GetHashCode(), id.GetHashCode());
|
||||
|
||||
Assert.Equal(id, JsonSerializer.Deserialize<RequestId>(JsonSerializer.Serialize(id)));
|
||||
}
|
||||
}
|
||||
|
|
@ -684,7 +684,7 @@ public class McpServerTests : LoggedTest
|
|||
|
||||
var notification = await notificationReceived.Task;
|
||||
var progress = (ProgressNotification)notification.Params!;
|
||||
Assert.Equal("\"abc\"", progress.ProgressToken.ToString());
|
||||
Assert.Equal("abc", progress.ProgressToken.ToString());
|
||||
Assert.Equal(50, progress.Progress.Progress);
|
||||
Assert.Equal(100, progress.Progress.Total);
|
||||
Assert.Equal("Progress message", progress.Progress.Message);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,9 @@
|
|||
// Uncomment to disable parallel test execution
|
||||
//[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
// Uncomment to disable parallel test execution for the whole assembly
|
||||
//[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
/// <summary>
|
||||
/// Enables test classes to individually be attributed as [Collection(nameof(DisableParallelization))]
|
||||
/// to have those tests run non-concurrently with any other tests.
|
||||
/// </summary>
|
||||
[CollectionDefinition(nameof(DisableParallelization), DisableParallelization = true)]
|
||||
public sealed class DisableParallelization;
|
||||
|
|
@ -213,7 +213,7 @@ public class SseClientTransportTests : LoggedTest
|
|||
Assert.True(session.MessageReader.TryRead(out var message));
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcRequest>(message);
|
||||
Assert.Equal("\"44\"", ((JsonRpcRequest)message).Id.ToString());
|
||||
Assert.Equal("44", ((JsonRpcRequest)message).Id.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Reference in a new issue