> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentcat.com/llms.txt
> Use this file to discover all available pages before exploring further.

# C#: logging, diagnostics, tests and shutdown

> Run a C# MCP server with AgentCat: the log file and LogToHost, diagnostics, AGENTCAT_ENABLED and test runs, tests with AgentCatSdk.Fake(), and delivery at shutdown.

This page covers what AgentCat does around your events in a C# server: where it logs, what it sends to find broken installs, when it turns itself off, how you test a tracked server, and how it sends the queued events when your server stops. It applies to [`WithAgentCat()`](https://docs.agentcat.com/sdk/csharp/aspnet-core) and to [`AgentCatSdk.Track()`](https://docs.agentcat.com/sdk/csharp/mcp-server-create), except where a section names one of them.

## The log file

AgentCat appends its lines to `~/agentcat.log`, in the home directory of the user that runs the process. Each line starts with the time in UTC and the versions of the SDK, of .NET and of the MCP SDK:

```text theme={null}
[2026-10-23T12:00:00.000Z] [sdk=1.0.0 dotnet=8.0.11 mcp=2.2.0] Successfully sent event <event ID> | mcp:tools/call | session <session ID> | <project ID> | 12 ms | <actor ID>
```

* **No debug option.** The log is always on. The C# SDK has no debug mode and does not read `AGENTCAT_DEBUG_MODE`.
* **Never stdout or stderr.** AgentCat writes nothing to the console, so the stdout of a stdio server stays the JSON-RPC stream.
* **No usable home directory.** When the process has none, or when AgentCat cannot open the file, it writes no log for the rest of the process. Your server runs as usual.
* **Several processes.** Server processes of one user can share the file. AgentCat opens the file for each line and writes the whole line.
* **While AgentCat is off** (see [Turn AgentCat on or off](#turn-agentcat-on-or-off)), it writes nothing and creates no file.

### Send the lines to your logger: `LogToHost`

With `LogToHost = true`, AgentCat sends its lines to the `ILoggerFactory` of the host, in the category `AgentCat`, instead of the file:

```csharp theme={null}
builder.Services.AddMcpServer()
    .WithHttpTransport(o => o.Stateless = true)
    .WithTools<MyTools>()
    .WithAgentCat("proj_YOUR_PROJECT_ID", options =>
    {
        options.LogToHost = true;
    });

// Keep the failure lines and drop the routine ones (see Levels below)
builder.Logging.AddFilter("AgentCat", LogLevel.Warning);
```

* **Levels.** A line that starts with `WARN:` is a warning. Else a line that contains `fail` or `error`, in any ASCII case, is an error. Else a line that contains `Warning:` is a warning. Every other line is information. Routine lines that carry a value of yours or an ID, such as the success line (it ends with the actor ID), the setup lines and the exporter lines, are always information, so a tool or actor named `search_errors` raises nothing. Lines that report a lost event (queue full, shutdown), an ignored custom event or tag, or an unknown exporter are warnings or errors whatever their text says. Your logger gets the message without the time and the version prefix.
* **One line for each event.** AgentCat logs `Successfully sent event …` at the Information level for each event that it delivers. Failure lines log at the Error level: for example `Failed to send event …`, `Failed to deliver AgentCat event after retries: …`, `Failed to initialize exporter …` and `Telemetry export failed for …`. The filter in the sample keeps every failure line and every warning, and drops the success lines. To see every line, leave the category at Information.
* **`WithAgentCat()` only.** Only dependency injection gives AgentCat a logger factory. `AgentCatSdk.Track()` keeps the file and logs `WARN: AgentCat LogToHost works only with WithAgentCat; Track keeps writing to the AgentCat log file` once.
* **The first call decides.** The first `WithAgentCat()` call with `LogToHost = true` whose service provider has an `ILoggerFactory` sends the lines of the whole process to that logger. Without a logger factory, AgentCat keeps the file.
* **stdio servers** send their console logs to stderr (`LogToStandardErrorThreshold = LogLevel.Trace`), because stdout carries the MCP messages. See [stdio on the generic host](https://docs.agentcat.com/sdk/csharp/aspnet-core.md#stdio-on-the-generic-host).

## Diagnostics

To find broken installs, AgentCat sends its own log lines to AgentCat (`https://otel.agentcat.com`): every line that it logs, as an OpenTelemetry log record, also when `LogToHost` sends the lines to your logger. These lines can contain:

* session IDs and actor IDs: AgentCat logs one line for each event it sends, in the form `Successfully sent event <event ID> | <event type> | session <session ID> | <project ID> | <duration> ms | <actor ID>`;
* tool names, and error messages, which hold whatever the code that threw put in them: your hooks, your options delegate and your `tools/list` handler included;
* the answers of exporters that failed, quoted (up to 64 KB), with the key part of a Sentry DSN replaced by `***`;
* the keys of the tags that AgentCat drops.

They carry no request or response payloads. Each batch also carries your project ID (without one, an install ID: a hash of the machine name and the install path), the versions of the SDK, of .NET and of the MCP SDK, the process ID, the OS type and version, the architecture, the number of CPUs, and `DOTNET_ENVIRONMENT` (else `ASPNETCORE_ENVIRONMENT`).

AgentCat sends a batch 2 seconds after the first line that it holds, with at most 1,000 lines (it drops the oldest), with no retry and a 10-second timeout. When the process stops, it sends the last batch after the events, and waits at most 1 second for it.

Diagnostics are on by default. They are off in a test run and while a fake is current. To turn them off:

* set `options.DisableDiagnostics = true` on the first `WithAgentCat()` or `Track()` of the process (the first install decides);
* or set the environment variable `AGENTCAT_DISABLE_DIAGNOSTICS=true`. The TypeScript name `DISABLE_DIAGNOSTICS=true` works too, but only to turn them off. When the two disagree, off wins. `AGENTCAT_DISABLE_DIAGNOSTICS=false` turns them on in a test run.

`AGENTCAT_DIAGNOSTICS_ENDPOINT` and `AGENTCAT_DIAGNOSTICS_TOKEN` send them to your own OpenTelemetry collector instead. The C# SDK does not read the unprefixed `DIAGNOSTICS_ENDPOINT` and `DIAGNOSTICS_TOKEN` of the TypeScript SDK, so your application's own variables of those names never reach AgentCat. Without `AGENTCAT_DIAGNOSTICS_TOKEN`, AgentCat sends its own diagnostics token to that collector.

## Turn AgentCat on or off

The `AGENTCAT_ENABLED` environment variable turns AgentCat on (`true`, `1`, `yes` or `on`) or off (`false`, `0`, `no` or `off`), in any case. Without it, AgentCat is on, except in a test run. For any other value, AgentCat uses that default and logs `WARN: AGENTCAT_ENABLED "<value>" is not one of true, 1, yes, on, false, 0, no, off; the default is used` once.

While AgentCat is off, `WithAgentCat()` and `Track()` install nothing and write nothing, not even the log file. Your server answers as it does without AgentCat: no `get_more_tools` tool and no injected parameters. `PublishCustomEventAsync` checks its arguments, then publishes nothing. One thing stays: on ASP.NET Core, `WithAgentCat()` registers `HttpContextAccessor` when your app has none, because AgentCat decides whether it is on only after the service provider is built. It costs about 70 ns on each HTTP request.

### Test runs

A test run is a process that loads xUnit, NUnit, MSTest or TUnit, or has one of them in its dependencies, or runs as the `testhost` of `dotnet test`. AgentCat is off there, so the tests of your server that count its tools or read `Content[0]` keep passing after you add AgentCat.

* **A server that your test starts as a child process** is not a test run: AgentCat tracks it and sends its events. Set `AGENTCAT_ENABLED=false` in the environment of that process.
* **A production app that ships a test framework** (one of them is in its dependencies) is a test run, so AgentCat stays off. Set `AGENTCAT_ENABLED=true` for that app.
* **`AGENTCAT_ENABLED=true` in a test run** turns AgentCat on, with real delivery. Diagnostics stay off unless `AGENTCAT_DISABLE_DIAGNOSTICS=false`.

## Testing

To assert on the events of a test, create a fake with `AgentCatSdk.Fake()` before you build the host or call `Track()`. While the fake is current, every server that AgentCat tracks records its events into the fake and sends nothing. This xUnit test runs a stateless server in memory, on the `TestServer` of `Microsoft.AspNetCore.TestHost`:

```csharp theme={null}
using AgentCat;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using Xunit;

public class EchoToolTests
{
    [Fact]
    public async Task The_echo_call_is_tracked()
    {
        var cancellationToken = TestContext.Current.CancellationToken;

        // First: in a test run, AgentCat is off until a fake is current
        using var fake = AgentCatSdk.Fake();

        var builder = WebApplication.CreateBuilder();
        builder.WebHost.UseTestServer();
        builder.Services.AddMcpServer()
            .WithHttpTransport(o => o.Stateless = true)
            .WithTools<MyTools>()
            .WithAgentCat("proj_YOUR_PROJECT_ID");

        await using var app = builder.Build();
        app.MapMcp();
        await app.StartAsync(cancellationToken);

        var transport = new HttpClientTransport(
            new HttpClientTransportOptions { Endpoint = new Uri("http://localhost/") },
            app.GetTestClient());
        await using var client = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken);
        await client.CallToolAsync(
            "echo",
            new Dictionary<string, object?> { ["message"] = "hi" },
            cancellationToken: cancellationToken);

        await fake.AssertTrackedCountAsync(1, cancellationToken);
        await fake.AssertTrackedAsync(e => e.ResourceName == "echo", cancellationToken);
    }
}
```

The fake runs every stage of a real event: your hooks, `RedactEvent`, `RedactSensitiveInformation`, sanitization, truncation and the event ID. Then it records the event and sends nothing. It also records the events of servers without a project ID. It does not set up exporters or send diagnostics, and its log lines go to `GetLogsAsync()`, not to the log file or to your logger.

| Method | What it does |
| - | - |
| `AssertTrackedAsync(filter, cancellationToken)` | Checks that at least one tool call (`mcp:tools/call`) was recorded, and that one matches the filter when you give one. Custom events do not count. |
| `AssertTrackedCountAsync(count, cancellationToken)` | Checks that exactly `count` tool calls were recorded. Custom events do not count. |
| `AssertNothingTrackedAsync(cancellationToken)` | Checks that no event of any type was recorded, custom events included. |
| `AssertCustomEventPublishedAsync(filter, cancellationToken)` | Checks that at least one custom event (`agentcat:custom`) was recorded, and that one matches the filter when you give one. |
| `GetEventsAsync(cancellationToken)` | Returns every recorded `AgentCatEvent`, in the order that AgentCat queued them. |
| `GetLogsAsync(cancellationToken)` | Returns the lines that AgentCat logged for this fake, without the time and the prefix: setup lines, hook errors and warnings. |

Each method first waits until AgentCat has processed the queued events, so the results of hooks that still run are in the events. The wait has no time limit: pass the cancellation token of your test framework. A failed assertion throws `AgentCatAssertionException`, and a cancelled token throws `OperationCanceledException`.

* **Create the fake first.** A stdio or stateful HTTP server builds its server options once. When it built them while AgentCat was off, it stays untracked, and a failed tool-call assertion says so. A stateless HTTP server builds new server options for each request, so a stateless server in a shared fixture follows the fake of each test: AgentCat tracks it from its next request.
* **One fake at a time.** The fake is process-wide. Tests that use a fake must not run in parallel with each other, or with other tests that use AgentCat. In xUnit, put them in one test collection with `DisableParallelization = true`.
* **No `Clear()`.** Create a new fake in each test: a new fake becomes current and starts empty. Dispose it at the end of the test.
* **After `Dispose()`,** the newest fake that is not disposed becomes current, or none. The servers that you set up under the disposed fake record into the current fake, or AgentCat drops their events. They never send them. While no fake is current, a stateless server that you set up under a fake serves its requests unchanged: no `get_more_tools`, no injected parameters and no session text. A stdio or stateful server keeps these changes for its lifetime.
* **`AGENTCAT_ENABLED` does not affect the fake.** While a fake is current, `FlushAsync` and `ShutdownAsync` act on the queue of the fake.

## Shutdown

AgentCat sends events from a process-wide queue in the background. When your server stops, AgentCat sends the events that are still queued:

* **After the host stops** (`WithAgentCat()`). AgentCat waits until every hosted service has stopped, the MCP stdio session and Kestrel included, so the events of tool calls that end during the stop are in the queue too. Then it sends the queued events for at most 5 seconds, and its last diagnostics batch for at most 1 second. It ignores the shutdown token of the host, so a stop can take up to 6 seconds longer than `HostOptions.ShutdownTimeout`. This flush does not close the queue: another host or server in the process keeps sending.
* **At process exit** (`AppDomain.ProcessExit`, `WithAgentCat()` and `Track()`). AgentCat closes the queue and sends the queued events, then its last diagnostics batch.
* **On SIGTERM and SIGINT** (`Track()` on .NET 8 and later). .NET 10 no longer raises `ProcessExit` on SIGTERM, so without these handlers a server without a host loses its queue on `docker stop`. The handlers send the queued events and never cancel the signal, so the process ends as usual, with its usual exit code. They do nothing when a handler that ran before them, the handler of a .NET host for example, cancelled the signal. `WithAgentCat()` adds no signal handler: the host owns the signals, and AgentCat flushes after the host stops.

### About 6 seconds for a stop

These paths share one stop budget: 5 seconds for the events, then at most 1 second for the diagnostics batch. The first path that starts sets the 5-second deadline, and a later one waits only for the time that is left. So a stop takes about 6 seconds at most, however many paths run, also when the AgentCat API does not answer.

A stop that sends every queued event ends its budget, so a process that keeps running gets a new budget at its next stop. When a stop runs out of time, a path that starts less than 6 seconds after the end of that stop joins it and waits for no events. A path that starts later is a new stop, with a new budget.

Set the grace period of your container above the time that your server needs to stop, plus these 6 seconds: `terminationGracePeriodSeconds` in Kubernetes, `docker stop --time`, or `stop_grace_period` in Docker Compose. The events that are still queued when the container is killed are lost.

### What the stop paths need

* **`Microsoft.Extensions.Hosting` 8 or later** for the flush after the host stops: AgentCat runs it in `IHostedLifecycleService.StoppedAsync`. Older hosts and the legacy `WebHost` never call it, so they rely on the process-exit handler.
* **A host.** Only a host runs the flush. If you build and dispose a service provider yourself, call `FlushAsync` or `ShutdownAsync` before you dispose it.
* **The first install decides.** The first `WithAgentCat()` or `Track()` of the process, while AgentCat is on, adds the handlers: `Track()` adds the process-exit and signal handlers, and `WithAgentCat()` adds only the process-exit handler. `DisableShutdownHandlers = true` on that install adds none, and the flush after the host stops stays on. An install while a fake is current does not count.
* **Signal order.** .NET 8 calls signal handlers in the order of their registration, and .NET 10 calls the newest first. So the handler of `Track()` can run before the handler of a .NET host in the same process: on .NET 10 when the host started before `Track()`, and on .NET 8 when it started after it. AgentCat then sends the queued events first, and the graceful stop of the host starts up to 6 seconds later.

### When no stop path runs

* **SIGKILL and unhandled exceptions.** No handler runs, and the queued events are lost.
* **.NET Framework.** All `ProcessExit` handlers of the process share 2 seconds, and AgentCat adds no signal handler.
* **The official C# stdio client kills the server.** When an `McpClient` over `StdioClientTransport` is disposed, it kills the server process instead of closing its input: at once on `ModelContextProtocol` 1.0 to 1.2, and after the `ShutdownTimeout` of the client on 1.3 and later. No flush runs, and the events that AgentCat has not sent by then are lost.

### `FlushAsync` and `ShutdownAsync`

Most servers never call these methods. Both are on `AgentCatSdk` and on the `IAgentCat` that `WithAgentCat()` registers, and neither throws.

```csharp theme={null}
using AgentCat;

// Wait at most 2 seconds for the queued events, without closing the queue
bool sent = await AgentCatSdk.FlushAsync(TimeSpan.FromSeconds(2));

// Your own code ends the process: close the queue and send the queued events for at most 5 seconds
bool nothingLeft = await AgentCatSdk.ShutdownAsync();
```

* **`FlushAsync(timeout, cancellationToken)`** waits until AgentCat has delivered the queued events and sent their exporter requests. It returns `true` when nothing is left, and `false` when the timeout or the token ended the wait first. Without a timeout, it waits without a limit, and a negative timeout counts as zero. The token ends only the wait: AgentCat keeps sending.
* **`ShutdownAsync(cancellationToken)`** closes the process-wide queue, sends the queued events for at most 5 seconds, then sends the last diagnostics batch and waits at most 1 second for it. It returns `true` when nothing was left. After it, AgentCat drops every new event of the process, tool-call events included, and logs `Queue is shutting down, event dropped` for each. Call it once, when your own code ends the process. A second call waits for the same drain.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.