Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/ModelContextProtocol.Core/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ 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);
Meter.CreateHistogram(name, "s", description, advice: longBuckets ? McpSecondsBucketBoundaries : ShortSecondsBucketBoundaries);
Comment thread
stephentoub marked this conversation as resolved.
Outdated

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

/// <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
/// 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> LongSecondsBucketBoundaries { get; } = new()
private static InstrumentAdvice<double> McpSecondsBucketBoundaries { get; } = new()
{
HistogramBucketBoundaries = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300],
};
Expand Down
45 changes: 31 additions & 14 deletions src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ namespace ModelContextProtocol;
/// </summary>
internal sealed partial class McpSessionHandler : IAsyncDisposable
{
// 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
// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md#metrics
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.", longBuckets: true);
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.", longBuckets: true);
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.", longBuckets: true);
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.", longBuckets: true);

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

// Per MCP semantic conventions: "pipe" for stdio, "tcp" or "quic" for HTTP
Comment thread
stephentoub marked this conversation as resolved.
Outdated
// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/mcp.md#recording-mcp-transport
_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",
_ => "pipe"
Comment thread
stephentoub marked this conversation as resolved.
Outdated
};

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

// TODO: When using SSE transport, add:
// TODO: When using HTTP 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
// - client.address and client.port on server spans (not metrics because of cardinality)
Comment thread
stephentoub marked this conversation as resolved.
Outdated
if (activity is { IsAllDataRequested: true })
{
// session and request id have high cardinality, so not applying to metric tags
activity.AddTag("mcp.session.id", _sessionId);

// Per semantic conventions: jsonrpc.request.id is a string representation of the id
Comment thread
stephentoub marked this conversation as resolved.
Outdated
if (message is JsonRpcMessageWithId withId)
{
activity.AddTag("mcp.request.id", withId.Id.Id?.ToString());
activity.AddTag("jsonrpc.request.id", withId.Id.Id?.ToString());
}
}

Expand All @@ -628,11 +633,22 @@ private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage messag
switch (method)
{
case RequestMethods.ToolsCall:
target = GetStringProperty(paramsObj, "name");
if (target is not null)
{
// Per semantic conventions: gen_ai.tool.name for tool operations
tags.Add("gen_ai.tool.name", target);
// Per semantic conventions: gen_ai.operation.name should be "execute_tool" for tool calls
tags.Add("gen_ai.operation.name", "execute_tool");
}
break;

case RequestMethods.PromptsGet:
target = GetStringProperty(paramsObj, "name");
if (target is not null)
{
tags.Add(method == RequestMethods.ToolsCall ? "mcp.tool.name" : "mcp.prompt.name", target);
// Per semantic conventions: gen_ai.prompt.name for prompt operations
tags.Add("gen_ai.prompt.name", target);
}
break;

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

if (activity is { IsAllDataRequested: true })
Expand Down
18 changes: 10 additions & 8 deletions tests/ModelContextProtocol.Tests/DiagnosticTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ await RunConnected(async (client, server) =>
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);

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);
Expand Down Expand Up @@ -94,38 +96,38 @@ await RunConnected(async (client, server) =>
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);

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

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

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