MCP Server Logging and OpenTelemetry in ASP.NET Core 10 Using C# – Complete Guide (2026)

A slow MCP call can come from transport negotiation, queueing, or the tool itself. The HTTP status alone cannot tell you which layer failed. An MCP server can return an HTTP success status while the tool response reports an error. Instrument both layers and give operators a way to join evidence without storing tool arguments.

This ASP.NET Core 10 lab emits structured logs, correlated OpenTelemetry traces, and bounded-cardinality tool metrics for a read-only sample tax quote. It exports traces and metrics to a local OTLP collector and writes application logs as JSON.

What should an MCP server measure?

Signal What to record What to avoid
Logs Stable operation name, outcome, trace ID Authorization headers and raw prompts
Traces Time spent inside HTTP and tool operations Customer data in span names or tags
Metrics Call count and duration with a small set of outcomes Trace IDs, full paths, or customer IDs as labels
An HTTP span encloses the tool span. Metrics aggregate by operation and outcome, while logs carry a trace ID for investigation.
An HTTP span encloses the tool span. Metrics aggregate by operation and outcome, while logs carry a trace ID for investigation.

The incoming HTTP activity is the parent of the application activity if propagation is available. A trace ID identifies the trace, while a span ID identifies one operation inside it. Neither value authenticates the caller. A progress token serves another purpose. It correlates interim protocol notifications with an active request and does not replace trace context.

The example chooses an application-owned ActivitySource and Meter so instrument names are stable even if SDK diagnostics evolve. Record expected invalid input as a distinct outcome. A transport-level 200 should never be your only tool success counter. See OpenTelemetry .NET instrumentation and Build an MCP server in C#.

Create the server

Install the .NET 10 SDK, Docker for the collector, and an MCP client. Create a web project and replace the generated project file and Program.cs. The dependency versions are reproducible lab pins. Review package advisories and use supported patches when deploying.

Create the project
dotnet new web -n TelemetryLab -f net10.0
cd TelemetryLab
Lab.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup><PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.3.1" /></ItemGroup>
  <ItemGroup><Compile Remove="ServerTests.cs" /></ItemGroup>
  <ItemGroup>
    <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.20.1" />
    <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.20.1" />
    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.20.1" />
  </ItemGroup>
</Project>
Program.cs
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Globalization;
using ModelContextProtocol;
using ModelContextProtocol.Server;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options => options.IncludeScopes = true);
builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("mcp-telemetry-lab"))
    .WithTracing(t => t.AddAspNetCoreInstrumentation()
        .AddSource(ToolSignals.Name).AddOtlpExporter())
    .WithMetrics(m => m.AddAspNetCoreInstrumentation()
        .AddMeter(ToolSignals.Name).AddOtlpExporter());
builder.Services.AddMcpServer().WithHttpTransport().WithTools<PriceTools>();
var app = builder.Build();
app.Use(async (context, next) =>
{
    if (context.Request.Headers.ContainsKey("Origin") ||
        context.Request.Host.Host != "127.0.0.1")
    {
        context.Response.StatusCode = 403;
        return;
    }

    await next(context);
});
app.MapMcp("/mcp");
await app.RunAsync();

static class ToolSignals
{
    public const string Name = "AspNetPb.Mcp.Tools";
    public static readonly ActivitySource Source = new(Name);
    private static readonly Meter Meter = new(Name);
    public static readonly Counter<long> Calls = Meter.CreateCounter<long>("mcp.tool.calls");
    public static readonly Histogram<double> Duration =
        Meter.CreateHistogram<double>("mcp.tool.duration", "ms");
}

[McpServerToolType]
public sealed class PriceTools
{
    [McpServerTool(Name = "quote_tax", ReadOnly = true)]
    [Description("Calculate sample tax for a bounded subtotal.")]
    public static string Quote(decimal subtotal, ILogger<PriceTools> logger)
    {
        using var span = ToolSignals.Source.StartActivity("mcp.tool quote_tax");
        using var scope = logger.BeginScope(new Dictionary<string, object?>
        {
            ["TraceId"] = Activity.Current?.TraceId.ToHexString()
        });
        var started = Stopwatch.GetTimestamp();
        var outcome = "error";
        try
        {
            if (subtotal is < 0 or > 10000)
            {
                outcome = "invalid";
                throw new McpException("Subtotal must be between 0 and 10000.");
            }

            var tax = decimal.Round(subtotal * 0.08m, 2, MidpointRounding.AwayFromZero);
            outcome = "ok";
            return tax.ToString("F2", CultureInfo.InvariantCulture);
        }
        finally
        {
            span?.SetTag("app.outcome", outcome);
            if (outcome == "error") span?.SetStatus(ActivityStatusCode.Error);
            var tags = new TagList { { "tool", "quote_tax" }, { "outcome", outcome } };
            ToolSignals.Calls.Add(1, tags);
            ToolSignals.Duration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds, tags);
            logger.LogInformation("Tool {Tool} ended with {Outcome}", "quote_tax", outcome);
        }
    }
}

The demo accepts only loopback-host requests without a browser Origin header and must be run with the loopback binding shown below. It deliberately has no remote authentication. Keep the tax calculation as illustrative arithmetic. Real tax rules are jurisdiction-specific.

Why do the logs and metrics contain different fields?

The log scope includes the trace ID to find one specific execution. The metric labels contain only a fixed tool name and one of three outcomes. A million unique trace IDs in a metric dimension would create a million separate series. Keep the exact subtotal out of all three signals. The diagnostic question is whether the tool completed and how long it took, and neither needs the customer’s input.

The histogram samples time from tool entry through the calculation. It does not include network latency, model deliberation, or request time before the tool handler. To isolate a slow dependency, add a child activity around the asynchronous operation and propagate cancellation. Choose buckets aligned with the service-level objective before using the histogram for percentile alerts.

Run a local collector and make a request

Save the configuration below as collector.yaml. The debug exporter is suitable for a disposable local demonstration. Do not route real customer traffic to it.

collector.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
exporters:
  debug:
    verbosity: detailed
service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      exporters: [debug]
Run collector and app in separate terminals
docker run --rm --name mcp-collector \
  -p 127.0.0.1:4317:4317 \
  -v "$PWD/collector.yaml:/etc/otelcol/config.yaml:ro" \
  otel/opentelemetry-collector:0.120.0

# In the app terminal
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_METRIC_EXPORT_INTERVAL=5000
dotnet run --no-launch-profile -- --urls http://127.0.0.1:5080

Connect a compatible MCP client to http://127.0.0.1:5080/mcp, list tools, then call quote_tax with {"subtotal":25}. The expected text result is 2.00. Try {"subtotal":-1} and confirm the MCP tool reports invalid input. Inspect stdout for JSON log records. The collector should show the HTTP and tool spans plus the counter and histogram after the export interval. Validate the parent relationship by comparing the child’s parent span ID with the HTTP span ID. Check the trace ID in the application log against the tool span. Exporters buffer data. Allow an export interval or shut down gracefully before diagnosing an empty collector.

The C# project compiled with .NET SDK 10.0.100. Docker and end-to-end OTLP export were not exercised in this environment.

Diagnose missing telemetry or noisy production signals

Symptom Likely cause Check
HTTP spans exist, but the tool span is missing Source was not registered or the tool was never entered Check AddSource and the tool result
Metrics appear late Periodic collection Wait for the export interval
OTLP connection fails Port, protocol, or TLS mismatch Compare gRPC endpoint and collector receiver
Failed tools look healthy Dashboard counts HTTP statuses only Filter tool metrics by bounded outcome
Logs and traces do not join Scope or sampling mismatch Include scopes and check the actual trace ID

In production, remove the debug exporter. Send OTLP over an authenticated channel, limit telemetry access and retention, and apply collector filtering as a second layer. The first layer is application code that never records the sensitive field. Test filtering with harmless sentinel strings, including inside thrown exceptions, model responses, nested attributes, and exporter failures. Watch for auto-instrumentation that adds headers or URLs you did not expect. Coordinate trace sampling with alerting. Counters can still show an error spike even if the corresponding trace was sampled out.

What is a useful alert?

Start with a sustained increase in error rate or duration, plus a minimum request count. Use separate thresholds for invalid inputs and internal errors. Verify that deployments do not reset your dashboard’s interpretation of cumulative counters. If a dependency is slow, use a representative trace to decide whether the bottleneck is connection acquisition, network I/O, or processing.

Frequently asked questions

Should I export the entire MCP request?

Avoid it by default. Instrument operation names, time, status, and safe counts. Collect a more detailed signal only after a specific privacy and retention review.

Why is ActivitySource.StartActivity nullable?

It can return null when there is no listener for that source. Instrumentation should never make the business operation depend on a trace being sampled or exported.

Can I export JSON console logs to the same backend?

Yes, through a compatible log collection pipeline. This sample leaves logs on stdout and exports traces and metrics over OTLP, so you can see the boundaries explicitly.

Conclusion and references

Start with one tool and verify all three signals against the same call. Use bounded labels for dashboards and trace IDs for investigation. Extend the pattern only where it helps distinguish real failure modes.

Comments