WithAgentCat() and to AgentCatSdk.Track(), 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:
- 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), 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:
- Levels. A line that starts with
WARN:is a warning. Else a line that containsfailorerror, in any ASCII case, is an error. Else a line that containsWarning: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 namedsearch_errorsraises 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 exampleFailed to send event …,Failed to deliver AgentCat event after retries: …,Failed to initialize exporter …andTelemetry 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 logsWARN: AgentCat LogToHost works only with WithAgentCat; Track keeps writing to the AgentCat log fileonce.- The first call decides. The first
WithAgentCat()call withLogToHost = truewhose service provider has anILoggerFactorysends 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.
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/listhandler 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.
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 = trueon the firstWithAgentCat()orTrack()of the process (the first install decides); - or set the environment variable
AGENTCAT_DISABLE_DIAGNOSTICS=true. The TypeScript nameDISABLE_DIAGNOSTICS=trueworks too, but only to turn them off. When the two disagree, off wins.AGENTCAT_DISABLE_DIAGNOSTICS=falseturns 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
TheAGENTCAT_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 thetesthost 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=falsein 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=truefor that app. AGENTCAT_ENABLED=truein a test run turns AgentCat on, with real delivery. Diagnostics stay off unlessAGENTCAT_DISABLE_DIAGNOSTICS=false.
Testing
To assert on the events of a test, create a fake withAgentCatSdk.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:
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.
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: noget_more_tools, no injected parameters and no session text. A stdio or stateful server keeps these changes for its lifetime. AGENTCAT_ENABLEDdoes not affect the fake. While a fake is current,FlushAsyncandShutdownAsyncact 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 thanHostOptions.ShutdownTimeout. This flush does not close the queue: another host or server in the process keeps sending. - At process exit (
AppDomain.ProcessExit,WithAgentCat()andTrack()). 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 raisesProcessExiton SIGTERM, so without these handlers a server without a host loses its queue ondocker 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.Hosting8 or later for the flush after the host stops: AgentCat runs it inIHostedLifecycleService.StoppedAsync. Older hosts and the legacyWebHostnever 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
FlushAsyncorShutdownAsyncbefore you dispose it. - The first install decides. The first
WithAgentCat()orTrack()of the process, while AgentCat is on, adds the handlers:Track()adds the process-exit and signal handlers, andWithAgentCat()adds only the process-exit handler.DisableShutdownHandlers = trueon 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 beforeTrack(), 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
ProcessExithandlers of the process share 2 seconds, and AgentCat adds no signal handler. - The official C# stdio client kills the server. When an
McpClientoverStdioClientTransportis disposed, it kills the server process instead of closing its input: at once onModelContextProtocol1.0 to 1.2, and after theShutdownTimeoutof 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.
FlushAsync(timeout, cancellationToken)waits until AgentCat has delivered the queued events and sent their exporter requests. It returnstruewhen nothing is left, andfalsewhen 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 returnstruewhen nothing was left. After it, AgentCat drops every new event of the process, tool-call events included, and logsQueue is shutting down, event droppedfor each. Call it once, when your own code ends the process. A second call waits for the same drain.