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:
Copilot 2026-01-13 18:07:49 -08:00 • committed by GitHub
commit d52a7b183d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 226 additions and 77 deletions

View file

@ -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);
}

View file

@ -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)];
}

View file

@ -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();
}
}
}

View file

@ -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,

View file

@ -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)