Recursive AI

Trace server tool calls in C#

OneModelContext is a free, open-source .NET SDK for the Model Context Protocol® from Recursive AI. MIT or Apache-2.0, no native dependencies.

Observe tool calls through .NET activities, the spans used by OpenTelemetry. No collector is needed for this local example.

Terminal
dotnet add package \
  RecursiveAI.OneModelContext

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using System.Diagnostics;
 
using var listener = new ActivityListener
{
    ShouldListenTo = source => source.Name ==
        "RecursiveAI.OneModelContext",
    Sample = (ref ActivityCreationOptions<
        ActivityContext> _) =>
        ActivitySamplingResult.AllData,
    ActivityStopped = activity =>
    {
        if (activity.Kind == ActivityKind.Server &&
            activity.DisplayName
                .StartsWith("tools/call"))
            Console.WriteLine(
                activity.DisplayName);
    }
};
ActivitySource.AddActivityListener(listener);

Registers the listener. Keep it alive in your server process while tool calls run; the full example below makes a call.

Unzip the example, then dotnet run.

The full program

The program creates a server with a hello tool and calls it through an in-memory client. The listener prints only completed server tool-call spans, followed by the client printing the result.

C#
using RecursiveAI.OneModelContext;
using RecursiveAI.OneModelContext.Protocol;
using System.Text.Json;
using System.Diagnostics;
 
using var listener = new ActivityListener
{
    ShouldListenTo = source =>
        source.Name == "RecursiveAI.OneModelContext",
    Sample = (ref ActivityCreationOptions<ActivityContext> _) =>
        ActivitySamplingResult.AllData,
    ActivityStopped = activity =>
    {
        if (activity.Kind == ActivityKind.Server &&
            activity.DisplayName.StartsWith("tools/call"))
            Console.WriteLine(activity.DisplayName);
    }
};
ActivitySource.AddActivityListener(listener);
var server = new ContextServer("Traces", "1.0");
using var schema = JsonDocument.Parse("""{"type":"object"}""");
server.Tools.Add(new ToolDefinition("hello", "Greet", schema),
    (_, _) => ValueTask.FromResult(CallToolResult.FromText("Hello")));
 
var toServer = new System.IO.Pipelines.Pipe();
var toClient = new System.IO.Pipelines.Pipe();
var serving = server.RunAsync(ContextTransport.Stream(
    toServer.Reader.AsStream(), toClient.Writer.AsStream()));
await using (var client = await ContextClient.ConnectAsync(
    ContextTransport.Stream(toClient.Reader.AsStream(), toServer.Writer.AsStream())))
{
    Console.WriteLine((await client.CallToolAsync("hello", new { })).Text);
}
await serving;

dotnet run prints:

Text
tools/call hello
Hello

Without a matching listener, the server does not start these activities; registering one after the call does not recover that call.

Variations

To export spans through OpenTelemetry, install:

Terminal
dotnet add package \
  OpenTelemetry.Extensions.Hosting
dotnet add package \
  OpenTelemetry.Exporter.Console
dotnet add package \
  Microsoft.Extensions.Hosting

The zip pins OpenTelemetry packages to 1.21.0 and Hosting to 10.0.0. Replace the full program’s ActivityListener setup with this code, keeping the using directives at the top:

C#
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OpenTelemetry.Trace;
 
var builder = Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddSource("RecursiveAI.OneModelContext")
        .AddConsoleExporter());
using var host = builder.Build();
await host.StartAsync();

Add this after await serving;:

C#
await host.StopAsync();

This was run with the same in-memory server and client. The console exporter printed discovery, server tool-call and client tool-call spans; this excerpt is from the actual output:

Text
Activity.DisplayName:        tools/call hello
Activity.Kind:               Server

It exports locally to the console, not to an external collector. Trace IDs and timestamps vary between runs.


The short answer

Listen to the RecursiveAI.OneModelContext activity source in the process running your server. Register the listener before handling calls and retain it until those calls finish. The server names a tool-call activity tools/call followed by the tool name, so the local example prints tools/call hello.

The snippet at the top installs observation; it does not make a call. The full program supplies the server, handler, client and invocation. It uses a local .NET ActivityListener, not an exporter, and requires no collector endpoint or service credentials.

How can I use OpenTelemetry in C#?

OneModelContext exposes an ActivitySource and a Meter, both named RecursiveAI.OneModelContext, according to its pinned README. For tracing, configure your telemetry pipeline to subscribe to that activity source. For local inspection without additional packages, use the listener shown above.

The source name is an exact match. A listener subscribed only to an HTTP instrumentation source does not subscribe to the library’s protocol activities. Likewise, enabling a meter is not the same as collecting spans: it subscribes to measurements rather than call activities.

The example chooses AllData so the activity is created and its completion callback runs. It filters on both ActivityKind.Server and the tools/call name prefix. That distinction matters when a process hosts both a client and a server, as this sample does. Without filtering, a single invocation can produce client and server observations, plus unrelated protocol operations.

What is the difference between a span and a trace in OpenTelemetry?

A span describes an operation; a trace connects related operations. The OpenTelemetry traces documentation explains spans, parent relationships and span kinds. In .NET, activities are the instrumentation objects observed here.

The server’s activity creation code uses ActivityKind.Server and checks for incoming trace context. When it can read a parent, it starts the activity with that parent. It does not make every observed call part of a single global trace merely because the source names match.

How do I visualize OpenTelemetry traces?

For a local check, the completion callback is enough to inspect the activity name and kind. For a timeline across services, use an OpenTelemetry collection and export pipeline that subscribes to the library’s source, then inspect the exported trace in your chosen backend. The variation configures and tests a console exporter, not an external backend.

Keep filtering and sampling separate. Filtering the output callback changes what this console prints; sampling controls which activities are created or recorded. The OpenTelemetry variation replaces the manual listener with a host-managed tracing pipeline. Dispose the listener when the server is shutting down rather than while requests are still running.

All onemodelcontext recipes