using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using OpenTelemetry.Trace; using System.Diagnostics; using System.IO.Pipelines; using System.Text; using System.Text.Json; namespace ModelContextProtocol.Tests; [Collection(nameof(DisableParallelization))] public class DiagnosticTests { [Fact] public async Task Session_TracksActivities() { var activities = new List(); var clientToServerLog = new List(); // Predicate for the expected server tool-call activity, including all required tags. // Defined here so it can be reused for both the wait and the assertion below. Func isExpectedServerToolCall = a => a.DisplayName == "tools/call DoubleValue" && a.Kind == ActivityKind.Server && a.Status == ActivityStatusCode.Unset && 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.Tags.Any(t => t.Key == "mcp.protocol.version" && !string.IsNullOrEmpty(t.Value)) && a.Tags.Any(t => t.Key == "mcp.session.id" && !string.IsNullOrEmpty(t.Value)); using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource("Experimental.ModelContextProtocol") .AddInMemoryExporter(activities) .Build()) { await RunConnected(async (client, server) => { var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(tools); Assert.NotEmpty(tools); var tool = tools.First(t => t.Name == "DoubleValue"); await tool.InvokeAsync(new() { ["amount"] = 42 }, TestContext.Current.CancellationToken); }, clientToServerLog); // Wait for server-side activities to be exported. The server processes messages // via fire-and-forget tasks, so activities may not be immediately available // after the client operation completes. Wait for the specific activity we need // (including required tags) rather than just the display name, so that we don't // assert before all tags have been populated. await WaitForAsync( () => activities.Any(isExpectedServerToolCall), failureMessage: "Timed out waiting for the expected server tool-call activity (tools/call DoubleValue) to be exported with required tags."); } Assert.NotEmpty(activities); var clientToolCall = Assert.Single(activities, a => 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 => isExpectedServerToolCall(a)); // 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); var clientListToolsCall = Assert.Single(activities, a => a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/list") && a.DisplayName == "tools/list" && a.Kind == ActivityKind.Client && a.Status == ActivityStatusCode.Unset); var serverListToolsCall = Assert.Single(activities, a => a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/list") && a.DisplayName == "tools/list" && a.Kind == ActivityKind.Server && a.Status == ActivityStatusCode.Unset); Assert.Equal(clientListToolsCall.SpanId, serverListToolsCall.ParentSpanId); Assert.Equal(clientListToolsCall.TraceId, serverListToolsCall.TraceId); // Validate that the client trace context encoded to request.params._meta[traceparent]. // Under the 2026-07-28 protocol _meta also carries the per-request envelope (protocolVersion, // clientInfo, clientCapabilities), so assert on the traceparent property specifically // rather than the entire _meta object. using var listToolsJson = JsonDocument.Parse(clientToServerLog.First(s => s.Contains("\"method\":\"tools/list\""))); var traceparent = listToolsJson.RootElement.GetProperty("params").GetProperty("_meta").GetProperty("traceparent").GetString(); Assert.Equal($"00-{clientListToolsCall.TraceId}-{clientListToolsCall.SpanId}-01", traceparent); // Validate that mcp.session.id is set on both client and server activities and that // all client activities share one session ID while all server activities share another. var clientSessionId = Assert.Single(clientToolCall.Tags, t => t.Key == "mcp.session.id").Value; var serverSessionId = Assert.Single(serverToolCall.Tags, t => t.Key == "mcp.session.id").Value; Assert.NotNull(clientSessionId); Assert.NotNull(serverSessionId); Assert.NotEqual(clientSessionId, serverSessionId); Assert.Equal(clientSessionId, clientListToolsCall.Tags.Single(t => t.Key == "mcp.session.id").Value); Assert.Equal(serverSessionId, serverListToolsCall.Tags.Single(t => t.Key == "mcp.session.id").Value); } [Fact] public async Task Session_FailedToolCall() { var activities = new List(); using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource("Experimental.ModelContextProtocol") .AddInMemoryExporter(activities) .Build()) { await RunConnected(async (client, server) => { await client.CallToolAsync("Throw", cancellationToken: TestContext.Current.CancellationToken); await Assert.ThrowsAsync(async () => await client.CallToolAsync("does-not-exist", cancellationToken: TestContext.Current.CancellationToken)); }, []); // Wait for server-side activities to be exported. Wait for specific activities // rather than a count, as other server activities may be exported first. await WaitForAsync(() => activities.Any(a => a.DisplayName == "tools/call Throw" && a.Kind == ActivityKind.Server) && activities.Any(a => a.DisplayName == "tools/call does-not-exist" && a.Kind == ActivityKind.Server)); } Assert.NotEmpty(activities); var throwToolClient = Assert.Single(activities, a => 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 == "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 == "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.response.status_code").Value); var doesNotExistToolServer = Assert.Single(activities, a => 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.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(); 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); }, []); // Wait for server-side activities to be exported. Wait for specific activities // rather than a count, as other server activities may be exported first. await WaitForAsync(() => activities.Any(a => a.DisplayName == "tools/call DoubleValue" && a.Kind == ActivityKind.Server)); } // 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 action, List clientToServerLog) { Pipe clientToServerPipe = new(), serverToClientPipe = new(); StreamServerTransport serverTransport = new(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()); StreamClientTransport clientTransport = new(new LoggingStream( clientToServerPipe.Writer.AsStream(), clientToServerLog.Add), serverToClientPipe.Reader.AsStream()); Task serverTask; await using (McpServer server = McpServer.Create(serverTransport, new() { ToolCollection = [ McpServerTool.Create((int amount) => amount * 2, new() { Name = "DoubleValue", Description = "Doubles the value." }), McpServerTool.Create(() => { throw new Exception("boom"); }, new() { Name = "Throw", Description = "Throws error." }), ] })) { serverTask = server.RunAsync(TestContext.Current.CancellationToken); await using (McpClient client = await McpClient.CreateAsync( clientTransport, cancellationToken: TestContext.Current.CancellationToken)) { await action(client, server); } } await serverTask; } private static async Task WaitForAsync(Func condition, int timeoutMs = 10_000, string? failureMessage = null) { using var cts = new CancellationTokenSource(timeoutMs); try { while (!condition()) { await Task.Delay(10, cts.Token); } } catch (TaskCanceledException) { throw new Xunit.Sdk.XunitException(failureMessage ?? $"Condition was not met within {timeoutMs}ms."); } } } public class LoggingStream : Stream { private readonly Stream _innerStream; private readonly Action _logAction; public LoggingStream(Stream innerStream, Action logAction) { _innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream)); _logAction = logAction ?? throw new ArgumentNullException(nameof(logAction)); } public override void Write(byte[] buffer, int offset, int count) { var data = Encoding.UTF8.GetString(buffer, offset, count); _logAction(data); _innerStream.Write(buffer, offset, count); } public override bool CanRead => _innerStream.CanRead; public override bool CanSeek => _innerStream.CanSeek; public override bool CanWrite => _innerStream.CanWrite; public override long Length => _innerStream.Length; public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; } public override void Flush() => _innerStream.Flush(); public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count); public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin); public override void SetLength(long value) => _innerStream.SetLength(value); }