Skip to content

Commit 6c0d9ae

Browse files
Copilotstephentoub
andcommitted
Update telemetry attributes to match MCP semantic conventions from open-telemetry/semantic-conventions#2083
- Change mcp.tool.name to gen_ai.tool.name - Change mcp.prompt.name to gen_ai.prompt.name - Change mcp.request.id to jsonrpc.request.id - Change rpc.jsonrpc.error_code to rpc.response.status_code - Update network.transport values from 'stdio' to 'pipe' and 'sse'/'http' to 'tcp' - Add gen_ai.operation.name attribute (value: 'execute_tool' for tools/call) - Update histogram bucket boundaries to match semantic conventions spec - Update tests to verify new attribute names Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 82204d5 commit 6c0d9ae

3 files changed

Lines changed: 45 additions & 26 deletions

File tree

src/ModelContextProtocol.Core/Diagnostics.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ internal static class Diagnostics
1313
internal static Meter Meter { get; } = new("Experimental.ModelContextProtocol");
1414

1515
internal static Histogram<double> CreateDurationHistogram(string name, string description, bool longBuckets) =>
16-
Meter.CreateHistogram(name, "s", description, advice: longBuckets ? LongSecondsBucketBoundaries : ShortSecondsBucketBoundaries);
16+
Meter.CreateHistogram(name, "s", description, advice: longBuckets ? McpSecondsBucketBoundaries : ShortSecondsBucketBoundaries);
1717

1818
/// <summary>
1919
/// Follows boundaries from http.server.request.duration/http.client.request.duration
@@ -24,10 +24,10 @@ internal static Histogram<double> CreateDurationHistogram(string name, string de
2424
};
2525

2626
/// <summary>
27-
/// Not based on a standard. Larger bucket sizes for longer lasting operations, e.g. HTTP connection duration.
28-
/// See https://github.com/open-telemetry/semantic-conventions/issues/336
27+
/// ExplicitBucketBoundaries specified in MCP semantic conventions for all MCP metrics.
28+
/// See https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md#metrics
2929
/// </summary>
30-
private static InstrumentAdvice<double> LongSecondsBucketBoundaries { get; } = new()
30+
private static InstrumentAdvice<double> McpSecondsBucketBoundaries { get; } = new()
3131
{
3232
HistogramBucketBoundaries = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300],
3333
};

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@ namespace ModelContextProtocol;
1919
/// </summary>
2020
internal sealed partial class McpSessionHandler : IAsyncDisposable
2121
{
22+
// MCP semantic conventions specify ExplicitBucketBoundaries of [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300] for all metrics
23+
// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md#metrics
2224
private static readonly Histogram<double> s_clientSessionDuration = Diagnostics.CreateDurationHistogram(
23-
"mcp.client.session.duration", "Measures the duration of a client session.", longBuckets: true);
25+
"mcp.client.session.duration", "The duration of the MCP session as observed on the MCP client.", longBuckets: true);
2426
private static readonly Histogram<double> s_serverSessionDuration = Diagnostics.CreateDurationHistogram(
25-
"mcp.server.session.duration", "Measures the duration of a server session.", longBuckets: true);
27+
"mcp.server.session.duration", "The duration of the MCP session as observed on the MCP server.", longBuckets: true);
2628
private static readonly Histogram<double> s_clientOperationDuration = Diagnostics.CreateDurationHistogram(
27-
"mcp.client.operation.duration", "Measures the duration of outbound message.", longBuckets: false);
29+
"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.", longBuckets: true);
2830
private static readonly Histogram<double> s_serverOperationDuration = Diagnostics.CreateDurationHistogram(
29-
"mcp.server.operation.duration", "Measures the duration of inbound message processing.", longBuckets: false);
31+
"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.", longBuckets: true);
3032

3133
/// <summary>The latest version of the protocol supported by this implementation.</summary>
3234
internal const string LatestProtocolVersion = "2025-06-18";
@@ -83,13 +85,15 @@ public McpSessionHandler(
8385
{
8486
Throw.IfNull(transport);
8587

88+
// Per MCP semantic conventions: "pipe" for stdio, "tcp" or "quic" for HTTP
89+
// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md#recording-mcp-transport
8690
_transportKind = transport switch
8791
{
88-
StdioClientSessionTransport or StdioServerTransport => "stdio",
89-
StreamClientSessionTransport or StreamServerTransport => "stream",
90-
SseClientSessionTransport or SseResponseStreamTransport => "sse",
91-
StreamableHttpClientSessionTransport or StreamableHttpServerTransport or StreamableHttpPostTransport => "http",
92-
_ => "unknownTransport"
92+
StdioClientSessionTransport or StdioServerTransport => "pipe",
93+
StreamClientSessionTransport or StreamServerTransport => "pipe",
94+
SseClientSessionTransport or SseResponseStreamTransport => "tcp",
95+
StreamableHttpClientSessionTransport or StreamableHttpServerTransport or StreamableHttpPostTransport => "tcp",
96+
_ => "pipe"
9397
};
9498

9599
_isServer = isServer;
@@ -598,17 +602,18 @@ private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage messag
598602
tags.Add("mcp.method.name", method);
599603
tags.Add("network.transport", _transportKind);
600604

601-
// TODO: When using SSE transport, add:
605+
// TODO: When using HTTP transport, add:
602606
// - server.address and server.port on client spans and metrics
603-
// - client.address and client.port on server spans (not metrics because of cardinality) when using SSE transport
607+
// - client.address and client.port on server spans (not metrics because of cardinality)
604608
if (activity is { IsAllDataRequested: true })
605609
{
606610
// session and request id have high cardinality, so not applying to metric tags
607611
activity.AddTag("mcp.session.id", _sessionId);
608612

613+
// Per semantic conventions: jsonrpc.request.id is a string representation of the id
609614
if (message is JsonRpcMessageWithId withId)
610615
{
611-
activity.AddTag("mcp.request.id", withId.Id.Id?.ToString());
616+
activity.AddTag("jsonrpc.request.id", withId.Id.Id?.ToString());
612617
}
613618
}
614619

@@ -628,11 +633,22 @@ private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage messag
628633
switch (method)
629634
{
630635
case RequestMethods.ToolsCall:
636+
target = GetStringProperty(paramsObj, "name");
637+
if (target is not null)
638+
{
639+
// Per semantic conventions: gen_ai.tool.name for tool operations
640+
tags.Add("gen_ai.tool.name", target);
641+
// Per semantic conventions: gen_ai.operation.name should be "execute_tool" for tool calls
642+
tags.Add("gen_ai.operation.name", "execute_tool");
643+
}
644+
break;
645+
631646
case RequestMethods.PromptsGet:
632647
target = GetStringProperty(paramsObj, "name");
633648
if (target is not null)
634649
{
635-
tags.Add(method == RequestMethods.ToolsCall ? "mcp.tool.name" : "mcp.prompt.name", target);
650+
// Per semantic conventions: gen_ai.prompt.name for prompt operations
651+
tags.Add("gen_ai.prompt.name", target);
636652
}
637653
break;
638654

@@ -670,7 +686,8 @@ private static void AddExceptionTags(ref TagList tags, Activity? activity, Excep
670686
tags.Add("error.type", errorType);
671687
if (intErrorCode is not null)
672688
{
673-
tags.Add("rpc.jsonrpc.error_code", errorType);
689+
// Per MCP semantic conventions: rpc.response.status_code for JSON-RPC error codes
690+
tags.Add("rpc.response.status_code", errorType);
674691
}
675692

676693
if (activity is { IsAllDataRequested: true })

tests/ModelContextProtocol.Tests/DiagnosticTests.cs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,17 @@ await RunConnected(async (client, server) =>
3737
Assert.NotEmpty(activities);
3838

3939
var clientToolCall = Assert.Single(activities, a =>
40-
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "DoubleValue") &&
40+
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "DoubleValue") &&
4141
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
42+
a.Tags.Any(t => t.Key == "gen_ai.operation.name" && t.Value == "execute_tool") &&
4243
a.DisplayName == "tools/call DoubleValue" &&
4344
a.Kind == ActivityKind.Client &&
4445
a.Status == ActivityStatusCode.Unset);
4546

4647
var serverToolCall = Assert.Single(activities, a =>
47-
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "DoubleValue") &&
48+
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "DoubleValue") &&
4849
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
50+
a.Tags.Any(t => t.Key == "gen_ai.operation.name" && t.Value == "execute_tool") &&
4951
a.DisplayName == "tools/call DoubleValue" &&
5052
a.Kind == ActivityKind.Server &&
5153
a.Status == ActivityStatusCode.Unset);
@@ -94,38 +96,38 @@ await RunConnected(async (client, server) =>
9496
Assert.NotEmpty(activities);
9597

9698
var throwToolClient = Assert.Single(activities, a =>
97-
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "Throw") &&
99+
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "Throw") &&
98100
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
99101
a.DisplayName == "tools/call Throw" &&
100102
a.Kind == ActivityKind.Client);
101103

102104
Assert.Equal(ActivityStatusCode.Error, throwToolClient.Status);
103105

104106
var throwToolServer = Assert.Single(activities, a =>
105-
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "Throw") &&
107+
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "Throw") &&
106108
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
107109
a.DisplayName == "tools/call Throw" &&
108110
a.Kind == ActivityKind.Server);
109111

110112
Assert.Equal(ActivityStatusCode.Error, throwToolServer.Status);
111113

112114
var doesNotExistToolClient = Assert.Single(activities, a =>
113-
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "does-not-exist") &&
115+
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "does-not-exist") &&
114116
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
115117
a.DisplayName == "tools/call does-not-exist" &&
116118
a.Kind == ActivityKind.Client);
117119

118120
Assert.Equal(ActivityStatusCode.Error, doesNotExistToolClient.Status);
119-
Assert.Equal("-32602", doesNotExistToolClient.Tags.Single(t => t.Key == "rpc.jsonrpc.error_code").Value);
121+
Assert.Equal("-32602", doesNotExistToolClient.Tags.Single(t => t.Key == "rpc.response.status_code").Value);
120122

121123
var doesNotExistToolServer = Assert.Single(activities, a =>
122-
a.Tags.Any(t => t.Key == "mcp.tool.name" && t.Value == "does-not-exist") &&
124+
a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "does-not-exist") &&
123125
a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") &&
124126
a.DisplayName == "tools/call does-not-exist" &&
125127
a.Kind == ActivityKind.Server);
126128

127129
Assert.Equal(ActivityStatusCode.Error, doesNotExistToolServer.Status);
128-
Assert.Equal("-32602", doesNotExistToolClient.Tags.Single(t => t.Key == "rpc.jsonrpc.error_code").Value);
130+
Assert.Equal("-32602", doesNotExistToolClient.Tags.Single(t => t.Key == "rpc.response.status_code").Value);
129131
}
130132

131133
private static async Task RunConnected(Func<McpClient, McpServer, Task> action, List<string> clientToServerLog)

0 commit comments

Comments
 (0)