Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)

_negotiatedProtocolVersion = initializeResponse.ProtocolVersion;

// Update session handler with the negotiated protocol version for telemetry
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;

// Send initialized notification
await this.SendNotificationAsync(
NotificationMethods.InitializedNotification,
Expand Down Expand Up @@ -230,6 +233,9 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions)
?? _options.ProtocolVersion
?? McpSessionHandler.LatestProtocolVersion;

// Update session handler with the negotiated protocol version for telemetry
_sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion;

LogClientSessionResumed(_endpointName);
}

Expand Down
29 changes: 29 additions & 0 deletions src/ModelContextProtocol.Core/Diagnostics.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -103,5 +104,33 @@ internal static bool ShouldInstrumentMessage(JsonRpcMessage message) =>
_ => false
};

/// <summary>
/// Per MCP semantic conventions: 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 with gen_ai.operation.name = execute_tool, 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)
{
activity = Activity.Current;
if (activity is null)
{
return false;
}
Comment thread
stephentoub marked this conversation as resolved.
Outdated

// Check if the current activity has gen_ai.operation.name = execute_tool
foreach (var tag in activity.Tags)
{
if (tag.Key == "gen_ai.operation.name" && tag.Value == "execute_tool")
{
return true;
}
}
Comment thread
stephentoub marked this conversation as resolved.
Outdated

activity = null;
return false;
}

internal static ActivityLink[] ActivityLinkFromCurrent() => Activity.Current is null ? [] : [new ActivityLink(Activity.Current.Context)];
}
175 changes: 133 additions & 42 deletions src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ 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");

// The negotiated MCP protocol version (set after initialization).
// Note: This is exposed via property for telemetry purposes.
private string? _negotiatedProtocolVersion;

private long _lastRequestId;

private CancellationTokenSource? _messageProcessingCts;
Expand Down Expand Up @@ -110,6 +115,15 @@ public McpSessionHandler(
/// </summary>
public string EndpointName { get; set; }

/// <summary>
/// Gets or sets the negotiated MCP protocol version for telemetry.
/// </summary>
public string? NegotiatedProtocolVersion
{
get => _negotiatedProtocolVersion;
set => _negotiatedProtocolVersion = value;
}
Comment thread
stephentoub marked this conversation as resolved.
Outdated

/// <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.
Expand Down Expand Up @@ -261,24 +275,40 @@ private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken
{
Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
string method = GetMethodName(message);
string? target = ExtractTargetFromMessage(message, method);
Comment thread
stephentoub marked this conversation as resolved.
Outdated

long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;

Activity? activity = Diagnostics.ShouldInstrumentMessage(message) ?
Diagnostics.ActivitySource.StartActivity(
CreateActivityName(method),
ActivityKind.Server,
parentContext: _propagator.ExtractActivityContext(message),
links: Diagnostics.ActivityLinkFromCurrent()) :
null;
// Per MCP semantic conventions: If outer GenAI instrumentation is already tracing the tool execution
// (i.e., Activity.Current has gen_ai.operation.name = execute_tool), we should add MCP attributes
// to that activity instead of creating a new one.
Activity? activity = null;
bool usingOuterActivity = false;
if (Diagnostics.ShouldInstrumentMessage(message))
{
if (method == RequestMethods.ToolsCall && Diagnostics.TryGetOuterToolExecutionActivity(out var outerActivity))
Comment thread
stephentoub marked this conversation as resolved.
Outdated
{
// Add MCP-specific attributes to the existing tool execution span
activity = outerActivity;
usingOuterActivity = true;
}
else
{
activity = Diagnostics.ActivitySource.StartActivity(
CreateActivityName(method, target),
ActivityKind.Server,
parentContext: _propagator.ExtractActivityContext(message),
links: Diagnostics.ActivityLinkFromCurrent());
}
}

TagList tags = default;
bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null;
try
{
if (addTags)
{
AddTags(ref tags, activity, message, method);
AddTags(ref tags, activity, message, method, target, usingOuterActivity);
}

switch (message)
Expand Down Expand Up @@ -319,7 +349,7 @@ private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken
}
finally
{
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags);
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags, disposeActivity: !usingOuterActivity);
}
}

Expand Down Expand Up @@ -422,11 +452,27 @@ public async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, Canc

Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
string method = request.Method;
string? target = ExtractTargetFromMessage(request, method);
Comment thread
stephentoub marked this conversation as resolved.
Outdated

long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
using Activity? activity = Diagnostics.ShouldInstrumentMessage(request) ?
Diagnostics.ActivitySource.StartActivity(McpSessionHandler.CreateActivityName(method), ActivityKind.Client) :
null;

// Per MCP semantic conventions: If outer GenAI instrumentation is already tracing the tool execution
// (i.e., Activity.Current has gen_ai.operation.name = execute_tool), we should add MCP attributes
// to that activity instead of creating a new one.
Activity? activity = null;
bool usingOuterActivity = false;
if (Diagnostics.ShouldInstrumentMessage(request))
{
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)
Expand All @@ -445,7 +491,7 @@ public async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, Canc
{
if (addTags)
{
AddTags(ref tags, activity, request, method);
AddTags(ref tags, activity, request, method, target, usingOuterActivity);
}

if (_logger.IsEnabled(LogLevel.Trace))
Expand Down Expand Up @@ -506,7 +552,7 @@ public async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, Canc
finally
{
_pendingRequests.TryRemove(request.Id, out _);
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags);
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags, disposeActivity: !usingOuterActivity);
}
}

Expand All @@ -518,10 +564,11 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can

Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;
string method = GetMethodName(message);
string? target = ExtractTargetFromMessage(message, method);

long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null;
using Activity? activity = Diagnostics.ShouldInstrumentMessage(message) ?
Diagnostics.ActivitySource.StartActivity(McpSessionHandler.CreateActivityName(method), ActivityKind.Client) :
Diagnostics.ActivitySource.StartActivity(CreateActivityName(method, target), ActivityKind.Client) :
null;

TagList tags = default;
Expand All @@ -534,7 +581,7 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can
{
if (addTags)
{
AddTags(ref tags, activity, message, method);
AddTags(ref tags, activity, message, method, target, usingOuterActivity: false);
}

if (_logger.IsEnabled(LogLevel.Trace))
Expand Down Expand Up @@ -565,7 +612,7 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can
}
finally
{
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags);
FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags, disposeActivity: true);
}
}

Expand All @@ -589,6 +636,38 @@ private Task SendToRelatedTransportAsync(JsonRpcMessage message, CancellationTok

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
{
Expand All @@ -597,11 +676,23 @@ private static string GetMethodName(JsonRpcMessage message) =>
_ => "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, bool usingOuterActivity)
{
tags.Add("mcp.method.name", method);
tags.Add("network.transport", _transportKind);

// Per semantic conventions: network.protocol.name when applicable (HTTP transports)
if (_transportKind == "tcp")
Comment thread
stephentoub marked this conversation as resolved.
Outdated
{
tags.Add("network.protocol.name", "http");
}

// Per semantic conventions: mcp.protocol.version is Recommended
Comment thread
stephentoub marked this conversation as resolved.
Outdated
if (_negotiatedProtocolVersion is not null)
{
tags.Add("mcp.protocol.version", _negotiatedProtocolVersion);
}

// 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)
Comment thread
stephentoub marked this conversation as resolved.
Outdated
Expand All @@ -615,25 +706,18 @@ private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage messag
{
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;
// If we're adding tags to an outer activity, we don't need to set DisplayName as it's already set
if (!usingOuterActivity && target is not null)
Comment thread
stephentoub marked this conversation as resolved.
Outdated
{
activity.DisplayName = $"{method} {target}";
}
}

string? target = null;
// Add target-specific tags based on method
switch (method)
{
case RequestMethods.ToolsCall:
target = GetStringProperty(paramsObj, "name");
if (target is not null)
{
// Per semantic conventions: gen_ai.tool.name for tool operations
Expand All @@ -644,7 +728,6 @@ private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage messag
break;

case RequestMethods.PromptsGet:
target = GetStringProperty(paramsObj, "name");
if (target is not null)
{
// Per semantic conventions: gen_ai.prompt.name for prompt operations
Expand All @@ -656,18 +739,22 @@ private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage messag
case RequestMethods.ResourcesSubscribe:
case RequestMethods.ResourcesUnsubscribe:
case NotificationMethods.ResourceUpdatedNotification:
target = GetStringProperty(paramsObj, "uri");
if (target is not null)
{
tags.Add("mcp.resource.uri", target);
// Get resource URI from params (not included in span name due to high cardinality)
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)
Expand Down Expand Up @@ -718,7 +805,7 @@ private static void AddResponseTags(ref TagList tags, Activity? activity, JsonNo
}

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
{
Expand All @@ -737,7 +824,11 @@ private static void FinalizeDiagnostics(
}
finally
{
activity?.Dispose();
// Only dispose the activity if we created it (not when reusing an outer GenAI activity)
if (disposeActivity)
{
activity?.Dispose();
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ private void ConfigureInitialize(McpServerOptions options)

_negotiatedProtocolVersion = protocolVersion;

// Update session handler with the negotiated protocol version for telemetry
_sessionHandler.NegotiatedProtocolVersion = protocolVersion;

return new InitializeResult
{
ProtocolVersion = protocolVersion,
Expand Down
Loading
Loading