Align telemetry with OpenTelemetry MCP semantic conventions (#1139)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
This commit is contained in:
parent
89b67b0e70
commit
d52a7b183d
5 changed files with 226 additions and 77 deletions
|
|
@ -187,6 +187,9 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
|
||||
_negotiatedProtocolVersion = initializeResponse.ProtocolVersion;
|
||||
|
||||
// Update session handler with the negotiated protocol version for telemetry
|
||||
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;
|
||||
|
||||
// Send initialized notification
|
||||
await this.SendNotificationAsync(
|
||||
NotificationMethods.InitializedNotification,
|
||||
|
|
@ -230,6 +233,9 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
?? _options.ProtocolVersion
|
||||
?? McpSessionHandler.LatestProtocolVersion;
|
||||
|
||||
// Update session handler with the negotiated protocol version for telemetry
|
||||
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;
|
||||
|
||||
LogClientSessionResumed(_endpointName);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
|
@ -12,22 +13,14 @@ internal static class Diagnostics
|
|||
|
||||
internal static Meter Meter { get; } = new("Experimental.ModelContextProtocol");
|
||||
|
||||
internal static Histogram<double> CreateDurationHistogram(string name, string description, bool longBuckets) =>
|
||||
Meter.CreateHistogram(name, "s", description, advice: longBuckets ? LongSecondsBucketBoundaries : ShortSecondsBucketBoundaries);
|
||||
internal static Histogram<double> CreateDurationHistogram(string name, string description) =>
|
||||
Meter.CreateHistogram(name, "s", description, advice: ExplicitBucketBoundaries);
|
||||
|
||||
/// <summary>
|
||||
/// Follows boundaries from http.server.request.duration/http.client.request.duration
|
||||
/// ExplicitBucketBoundaries specified in MCP semantic conventions for all MCP metrics.
|
||||
/// See https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md#metrics
|
||||
/// </summary>
|
||||
private 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>
|
||||
private static InstrumentAdvice<double> LongSecondsBucketBoundaries { get; } = new()
|
||||
private static InstrumentAdvice<double> ExplicitBucketBoundaries { get; } = new()
|
||||
{
|
||||
HistogramBucketBoundaries = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300],
|
||||
};
|
||||
|
|
@ -103,5 +96,25 @@ internal static class Diagnostics
|
|||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// If outer GenAI instrumentation is already tracing the tool execution,
|
||||
/// MCP instrumentation SHOULD add MCP-specific attributes to the existing tool execution span instead
|
||||
/// of creating a new one.
|
||||
/// </summary>
|
||||
/// <param name="activity">The outer activity for tool execution, if found.</param>
|
||||
/// <returns>true if an outer tool execution activity was found and can be reused; false otherwise.</returns>
|
||||
internal static bool TryGetOuterToolExecutionActivity([NotNullWhen(true)] out Activity? activity)
|
||||
{
|
||||
if (Activity.Current is { } currentActivity &&
|
||||
currentActivity.OperationName.StartsWith("execute_tool ", StringComparison.Ordinal))
|
||||
{
|
||||
activity = currentActivity;
|
||||
return true;
|
||||
}
|
||||
|
||||
activity = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static ActivityLink[] ActivityLinkFromCurrent() => Activity.Current is null ? [] : [new ActivityLink(Activity.Current.Context)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ namespace ModelContextProtocol;
|
|||
internal sealed partial class McpSessionHandler : IAsyncDisposable
|
||||
{
|
||||
private static readonly Histogram<double> s_clientSessionDuration = Diagnostics.CreateDurationHistogram(
|
||||
"mcp.client.session.duration", "Measures the duration of a client session.", longBuckets: true);
|
||||
"mcp.client.session.duration", "The duration of the MCP session as observed on the MCP client.");
|
||||
private static readonly Histogram<double> s_serverSessionDuration = Diagnostics.CreateDurationHistogram(
|
||||
"mcp.server.session.duration", "Measures the duration of a server session.", longBuckets: true);
|
||||
"mcp.server.session.duration", "The duration of the MCP session as observed on the MCP server.");
|
||||
private static readonly Histogram<double> s_clientOperationDuration = Diagnostics.CreateDurationHistogram(
|
||||
"mcp.client.operation.duration", "Measures the duration of outbound message.", longBuckets: false);
|
||||
"mcp.client.operation.duration", "The duration of the MCP request or notification as observed on the sender from the time it was sent until the response or ack is received.");
|
||||
private static readonly Histogram<double> s_serverOperationDuration = Diagnostics.CreateDurationHistogram(
|
||||
"mcp.server.operation.duration", "Measures the duration of inbound message processing.", longBuckets: false);
|
||||
"mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent.");
|
||||
|
||||
/// <summary>The latest version of the protocol supported by this implementation.</summary>
|
||||
internal const string LatestProtocolVersion = "2025-06-18";
|
||||
|
|
@ -59,6 +59,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
// This _sessionId is solely used to identify the session in telemetry and logs.
|
||||
private readonly string _sessionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
private long _lastRequestId;
|
||||
|
||||
private CancellationTokenSource? _messageProcessingCts;
|
||||
|
|
@ -85,11 +86,11 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
_transportKind = transport switch
|
||||
{
|
||||
StdioClientSessionTransport or StdioServerTransport => "stdio",
|
||||
StreamClientSessionTransport or StreamServerTransport => "stream",
|
||||
SseClientSessionTransport or SseResponseStreamTransport => "sse",
|
||||
StreamableHttpClientSessionTransport or StreamableHttpServerTransport or StreamableHttpPostTransport => "http",
|
||||
_ => "unknownTransport"
|
||||
StdioClientSessionTransport or StdioServerTransport => "pipe",
|
||||
StreamClientSessionTransport or StreamServerTransport => "pipe",
|
||||
SseClientSessionTransport or SseResponseStreamTransport => "tcp",
|
||||
StreamableHttpClientSessionTransport or StreamableHttpServerTransport or StreamableHttpPostTransport => "tcp",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
_isServer = isServer;
|
||||
|
|
@ -106,6 +107,11 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
/// </summary>
|
||||
public string EndpointName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the negotiated MCP protocol version for telemetry.
|
||||
/// </summary>
|
||||
public string? NegotiatedProtocolVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts processing messages from the transport. This method will block until the transport is disconnected.
|
||||
/// This is generally started in a background task or thread from the initialization logic of the derived class.
|
||||
|
|
@ -260,13 +266,17 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
|
||||
Activity? activity = Diagnostics.ShouldInstrumentMessage(message) ?
|
||||
Diagnostics.ActivitySource.StartActivity(
|
||||
CreateActivityName(method),
|
||||
Activity? activity = null;
|
||||
string? target = null;
|
||||
if (Diagnostics.ShouldInstrumentMessage(message))
|
||||
{
|
||||
target = ExtractTargetFromMessage(message, method);
|
||||
activity = Diagnostics.ActivitySource.StartActivity(
|
||||
CreateActivityName(method, target),
|
||||
ActivityKind.Server,
|
||||
parentContext: _propagator.ExtractActivityContext(message),
|
||||
links: Diagnostics.ActivityLinkFromCurrent()) :
|
||||
null;
|
||||
links: Diagnostics.ActivityLinkFromCurrent());
|
||||
}
|
||||
|
||||
TagList tags = default;
|
||||
bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null;
|
||||
|
|
@ -274,7 +284,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
if (addTags)
|
||||
{
|
||||
AddTags(ref tags, activity, message, method);
|
||||
AddTags(ref tags, activity, message, method, target);
|
||||
}
|
||||
|
||||
switch (message)
|
||||
|
|
@ -420,9 +430,25 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
string method = request.Method;
|
||||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
using Activity? activity = Diagnostics.ShouldInstrumentMessage(request) ?
|
||||
Diagnostics.ActivitySource.StartActivity(McpSessionHandler.CreateActivityName(method), ActivityKind.Client) :
|
||||
null;
|
||||
|
||||
// If outer GenAI instrumentation is already tracing the tool execution,
|
||||
// add MCP attributes to that activity instead of creating a new one.
|
||||
Activity? activity = null;
|
||||
bool usingOuterActivity = false;
|
||||
string? target = null;
|
||||
if (Diagnostics.ShouldInstrumentMessage(request))
|
||||
{
|
||||
target = ExtractTargetFromMessage(request, method);
|
||||
if (method == RequestMethods.ToolsCall && Diagnostics.TryGetOuterToolExecutionActivity(out var outerActivity))
|
||||
{
|
||||
activity = outerActivity;
|
||||
usingOuterActivity = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
activity = Diagnostics.ActivitySource.StartActivity(CreateActivityName(method, target), ActivityKind.Client);
|
||||
}
|
||||
}
|
||||
|
||||
// Set request ID
|
||||
if (request.Id.Id is null)
|
||||
|
|
@ -441,7 +467,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
if (addTags)
|
||||
{
|
||||
AddTags(ref tags, activity, request, method);
|
||||
AddTags(ref tags, activity, request, method, target);
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
|
|
@ -502,7 +528,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
finally
|
||||
{
|
||||
_pendingRequests.TryRemove(request.Id, out _);
|
||||
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags);
|
||||
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags, disposeActivity: !usingOuterActivity);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -516,9 +542,14 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
string method = GetMethodName(message);
|
||||
|
||||
long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
|
||||
using Activity? activity = Diagnostics.ShouldInstrumentMessage(message) ?
|
||||
Diagnostics.ActivitySource.StartActivity(McpSessionHandler.CreateActivityName(method), ActivityKind.Client) :
|
||||
null;
|
||||
|
||||
Activity? activity = null;
|
||||
string? target = null;
|
||||
if (Diagnostics.ShouldInstrumentMessage(message))
|
||||
{
|
||||
target = ExtractTargetFromMessage(message, method);
|
||||
activity = Diagnostics.ActivitySource.StartActivity(CreateActivityName(method, target), ActivityKind.Client);
|
||||
}
|
||||
|
||||
TagList tags = default;
|
||||
bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null;
|
||||
|
|
@ -530,7 +561,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
{
|
||||
if (addTags)
|
||||
{
|
||||
AddTags(ref tags, activity, message, method);
|
||||
AddTags(ref tags, activity, message, method, target);
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
|
|
@ -585,6 +616,38 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
|
||||
private static string CreateActivityName(string method) => method;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a span name according to semantic conventions: "{mcp.method.name} {target}" where
|
||||
/// target is the tool name, prompt name, or resource URI when applicable.
|
||||
/// </summary>
|
||||
private static string CreateActivityName(string method, string? target) =>
|
||||
target is null ? method : $"{method} {target}";
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the target (tool name, prompt name, or resource URI) from a message for use in span naming.
|
||||
/// </summary>
|
||||
private static string? ExtractTargetFromMessage(JsonRpcMessage message, string method)
|
||||
{
|
||||
JsonObject? paramsObj = message switch
|
||||
{
|
||||
JsonRpcRequest request => request.Params as JsonObject,
|
||||
JsonRpcNotification notification => notification.Params as JsonObject,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (paramsObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return method switch
|
||||
{
|
||||
RequestMethods.ToolsCall or RequestMethods.PromptsGet => GetStringProperty(paramsObj, "name"),
|
||||
// Note: resource URI is not included in span name by default due to high cardinality per semantic conventions
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetMethodName(JsonRpcMessage message) =>
|
||||
message switch
|
||||
{
|
||||
|
|
@ -593,46 +656,45 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
_ => "unknownMethod"
|
||||
};
|
||||
|
||||
private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage message, string method)
|
||||
private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage message, string method, string? target)
|
||||
{
|
||||
tags.Add("mcp.method.name", method);
|
||||
tags.Add("network.transport", _transportKind);
|
||||
|
||||
// TODO: When using SSE transport, add:
|
||||
// - server.address and server.port on client spans and metrics
|
||||
// - client.address and client.port on server spans (not metrics because of cardinality) when using SSE transport
|
||||
if (_transportKind is "tcp")
|
||||
{
|
||||
tags.Add("network.protocol.name", "http");
|
||||
}
|
||||
|
||||
if (NegotiatedProtocolVersion is not null)
|
||||
{
|
||||
tags.Add("mcp.protocol.version", NegotiatedProtocolVersion);
|
||||
}
|
||||
|
||||
if (activity is { IsAllDataRequested: true })
|
||||
{
|
||||
// session and request id have high cardinality, so not applying to metric tags
|
||||
activity.AddTag("mcp.session.id", _sessionId);
|
||||
|
||||
if (message is JsonRpcMessageWithId withId)
|
||||
{
|
||||
activity.AddTag("mcp.request.id", withId.Id.Id?.ToString());
|
||||
activity.AddTag("jsonrpc.request.id", withId.Id.Id?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
JsonObject? paramsObj = message switch
|
||||
{
|
||||
JsonRpcRequest request => request.Params as JsonObject,
|
||||
JsonRpcNotification notification => notification.Params as JsonObject,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (paramsObj == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? target = null;
|
||||
switch (method)
|
||||
{
|
||||
case RequestMethods.ToolsCall:
|
||||
case RequestMethods.PromptsGet:
|
||||
target = GetStringProperty(paramsObj, "name");
|
||||
if (target is not null)
|
||||
{
|
||||
tags.Add(method == RequestMethods.ToolsCall ? "mcp.tool.name" : "mcp.prompt.name", target);
|
||||
tags.Add("gen_ai.tool.name", target);
|
||||
tags.Add("gen_ai.operation.name", "execute_tool");
|
||||
}
|
||||
break;
|
||||
|
||||
case RequestMethods.PromptsGet:
|
||||
if (target is not null)
|
||||
{
|
||||
tags.Add("gen_ai.prompt.name", target);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -640,18 +702,21 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
case RequestMethods.ResourcesSubscribe:
|
||||
case RequestMethods.ResourcesUnsubscribe:
|
||||
case NotificationMethods.ResourceUpdatedNotification:
|
||||
target = GetStringProperty(paramsObj, "uri");
|
||||
if (target is not null)
|
||||
{
|
||||
tags.Add("mcp.resource.uri", target);
|
||||
JsonObject? paramsObj = message switch
|
||||
{
|
||||
JsonRpcRequest request => request.Params as JsonObject,
|
||||
JsonRpcNotification notification => notification.Params as JsonObject,
|
||||
_ => null
|
||||
};
|
||||
string? uri = paramsObj is not null ? GetStringProperty(paramsObj, "uri") : null;
|
||||
if (uri is not null)
|
||||
{
|
||||
tags.Add("mcp.resource.uri", uri);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (activity is { IsAllDataRequested: true })
|
||||
{
|
||||
activity.DisplayName = target == null ? method : $"{method} {target}";
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddExceptionTags(ref TagList tags, Activity? activity, Exception e)
|
||||
|
|
@ -670,7 +735,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
tags.Add("error.type", errorType);
|
||||
if (intErrorCode is not null)
|
||||
{
|
||||
tags.Add("rpc.jsonrpc.error_code", errorType);
|
||||
tags.Add("rpc.response.status_code", errorType);
|
||||
}
|
||||
|
||||
if (activity is { IsAllDataRequested: true })
|
||||
|
|
@ -701,7 +766,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
}
|
||||
|
||||
private static void FinalizeDiagnostics(
|
||||
Activity? activity, long? startingTimestamp, Histogram<double> durationMetric, ref TagList tags)
|
||||
Activity? activity, long? startingTimestamp, Histogram<double> durationMetric, ref TagList tags, bool disposeActivity = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -720,7 +785,11 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
}
|
||||
finally
|
||||
{
|
||||
activity?.Dispose();
|
||||
// Only dispose the activity if we created it (not when reusing an outer GenAI activity)
|
||||
if (disposeActivity)
|
||||
{
|
||||
activity?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -213,6 +213,9 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
_negotiatedProtocolVersion = protocolVersion;
|
||||
|
||||
// Update session handler with the negotiated protocol version for telemetry
|
||||
_sessionHandler.NegotiatedProtocolVersion = protocolVersion;
|
||||
|
||||
return new InitializeResult
|
||||
{
|
||||
ProtocolVersion = protocolVersion,
|
||||
|
|
|
|||
|
|
@ -37,19 +37,27 @@ public class DiagnosticTests
|
|||
Assert.NotEmpty(activities);
|
||||
|
||||
var clientToolCall = Assert.Single(activities, a =>
|
||||
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "DoubleValue") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "DoubleValue") &&
|
||||
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.operation.name" && t.Value == "execute_tool") &&
|
||||
a.DisplayName == "tools/call DoubleValue" &&
|
||||
a.Kind == ActivityKind.Client &&
|
||||
a.Status == ActivityStatusCode.Unset);
|
||||
|
||||
// Per semantic conventions: mcp.protocol.version should be present after initialization
|
||||
Assert.Contains(clientToolCall.Tags, t => t.Key == "mcp.protocol.version" && !string.IsNullOrEmpty(t.Value));
|
||||
|
||||
var serverToolCall = Assert.Single(activities, a =>
|
||||
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "DoubleValue") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "DoubleValue") &&
|
||||
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.operation.name" && t.Value == "execute_tool") &&
|
||||
a.DisplayName == "tools/call DoubleValue" &&
|
||||
a.Kind == ActivityKind.Server &&
|
||||
a.Status == ActivityStatusCode.Unset);
|
||||
|
||||
// Per semantic conventions: mcp.protocol.version should be present after initialization
|
||||
Assert.Contains(serverToolCall.Tags, t => t.Key == "mcp.protocol.version" && !string.IsNullOrEmpty(t.Value));
|
||||
|
||||
Assert.Equal(clientToolCall.SpanId, serverToolCall.ParentSpanId);
|
||||
Assert.Equal(clientToolCall.TraceId, serverToolCall.TraceId);
|
||||
|
||||
|
|
@ -94,7 +102,7 @@ public class DiagnosticTests
|
|||
Assert.NotEmpty(activities);
|
||||
|
||||
var throwToolClient = Assert.Single(activities, a =>
|
||||
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "Throw") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "Throw") &&
|
||||
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
|
||||
a.DisplayName == "tools/call Throw" &&
|
||||
a.Kind == ActivityKind.Client);
|
||||
|
|
@ -102,7 +110,7 @@ public class DiagnosticTests
|
|||
Assert.Equal(ActivityStatusCode.Error, throwToolClient.Status);
|
||||
|
||||
var throwToolServer = Assert.Single(activities, a =>
|
||||
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "Throw") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "Throw") &&
|
||||
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
|
||||
a.DisplayName == "tools/call Throw" &&
|
||||
a.Kind == ActivityKind.Server);
|
||||
|
|
@ -110,22 +118,72 @@ public class DiagnosticTests
|
|||
Assert.Equal(ActivityStatusCode.Error, throwToolServer.Status);
|
||||
|
||||
var doesNotExistToolClient = Assert.Single(activities, a =>
|
||||
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "does-not-exist") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "does-not-exist") &&
|
||||
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
|
||||
a.DisplayName == "tools/call does-not-exist" &&
|
||||
a.Kind == ActivityKind.Client);
|
||||
|
||||
Assert.Equal(ActivityStatusCode.Error, doesNotExistToolClient.Status);
|
||||
Assert.Equal("-32602", doesNotExistToolClient.Tags.Single(t => t.Key == "rpc.jsonrpc.error_code").Value);
|
||||
Assert.Equal("-32602", doesNotExistToolClient.Tags.Single(t => t.Key == "rpc.response.status_code").Value);
|
||||
|
||||
var doesNotExistToolServer = Assert.Single(activities, a =>
|
||||
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "does-not-exist") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "does-not-exist") &&
|
||||
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
|
||||
a.DisplayName == "tools/call does-not-exist" &&
|
||||
a.Kind == ActivityKind.Server);
|
||||
|
||||
Assert.Equal(ActivityStatusCode.Error, doesNotExistToolServer.Status);
|
||||
Assert.Equal("-32602", doesNotExistToolClient.Tags.Single(t => t.Key == "rpc.jsonrpc.error_code").Value);
|
||||
Assert.Equal("-32602", doesNotExistToolClient.Tags.Single(t => t.Key == "rpc.response.status_code").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_McpAttributesAddedToOuterExecuteToolActivity()
|
||||
{
|
||||
// This test simulates the scenario where FunctionInvokingChatClient creates an outer
|
||||
// "execute_tool" activity, and MCP should add its attributes to that activity instead
|
||||
// of creating a new one.
|
||||
string outerSourceName = "TestOuterSource";
|
||||
var activities = new List<Activity>();
|
||||
|
||||
using var outerSource = new ActivitySource(outerSourceName);
|
||||
|
||||
using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(outerSourceName)
|
||||
.AddSource("Experimental.ModelContextProtocol")
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build())
|
||||
{
|
||||
await RunConnected(async (client, server) =>
|
||||
{
|
||||
// Simulate FunctionInvokingChatClient creating an outer activity
|
||||
using var outerActivity = outerSource.StartActivity("execute_tool DoubleValue");
|
||||
Assert.NotNull(outerActivity);
|
||||
|
||||
// Now call the MCP tool - MCP should augment the outer activity
|
||||
var tool = (await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken))
|
||||
.First(t => t.Name == "DoubleValue");
|
||||
await tool.InvokeAsync(new() { ["amount"] = 42 }, TestContext.Current.CancellationToken);
|
||||
}, []);
|
||||
}
|
||||
|
||||
// The outer activity should have MCP-specific attributes added to it
|
||||
var outerExecuteToolActivity = Assert.Single(activities, a =>
|
||||
a.Source.Name == outerSourceName &&
|
||||
a.DisplayName == "execute_tool DoubleValue" &&
|
||||
a.Kind == ActivityKind.Internal);
|
||||
|
||||
// MCP should have added its attributes to the outer activity
|
||||
Assert.Contains(outerExecuteToolActivity.Tags, t => t.Key == "mcp.method.name" && t.Value == "tools/call");
|
||||
Assert.Contains(outerExecuteToolActivity.Tags, t => t.Key == "gen_ai.tool.name" && t.Value == "DoubleValue");
|
||||
Assert.Contains(outerExecuteToolActivity.Tags, t => t.Key == "gen_ai.operation.name" && t.Value == "execute_tool");
|
||||
|
||||
// Verify that no separate MCP client activity was created for this tool call
|
||||
var mcpClientActivities = activities.Where(a =>
|
||||
a.Source.Name == "Experimental.ModelContextProtocol" &&
|
||||
a.Kind == ActivityKind.Client &&
|
||||
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
|
||||
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "DoubleValue"));
|
||||
Assert.Empty(mcpClientActivities);
|
||||
}
|
||||
|
||||
private static async Task RunConnected(Func<McpClient, McpServer, Task> action, List<string> clientToServerLog)
|
||||
|
|
|
|||
Loading…
Reference in a new issue