Progress notifications let a client show work completed while a tool call is still running.
dotnet add package RecursiveAI.OneModelContextRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
using RecursiveAI.OneModelContext; await ModelContext.ServeAsync(new Tools()); public partial class Tools { [Tool("work")] public async Task<string> Work(RequestContext context) { await context.ReportProgressAsync(1, 1); return "Finished"; } }
Without a progress token from the caller, ReportProgressAsync does nothing. A client must request progress to see any updates; this handler sends 1 of 1, then returns Finished.
Unzip the example, then dotnet run.
The download runs a client and child server over standard input and output. Its handler reports three completed work units; its client supplies an IProgress<ProgressNotificationParams> receiver. The package’s generator supplies tool registration.
The full program
using RecursiveAI.OneModelContext; using RecursiveAI.OneModelContext.Protocol; using System.Reflection; using System.Text.Json; if (args.Contains("--stdio")) { await ModelContext.ServeAsync(new Demo()); return; } var assembly = Assembly.GetExecutingAssembly().Location; await using var client = await ModelContext.ConnectAsync($"dotnet \"{assembly}\" --stdio"); using var arguments = JsonDocument.Parse("{}"); var progress = new InlineProgress(); var result = await client.CallToolAsync("work", arguments.RootElement, progress); Console.WriteLine(result.Text); public partial class Demo { [Tool("work")] public async Task<string> Work(RequestContext context) { for (int done = 1; done <= 3; done++) { await Task.Delay(50, context.CancellationToken); await context.ReportProgressAsync(done, 3, "Working"); } return "Finished"; } } public sealed class InlineProgress : IProgress<ProgressNotificationParams> { public void Report(ProgressNotificationParams update) => Console.WriteLine($"{update.Progress}/{update.Total}: {update.Message}"); }
dotnet run prints:
1/3: Working 2/3: Working 3/3: Working Finished
Variations
Report completed units without a known total. Replace the progress call inside the full program’s loop with:
await context.ReportProgressAsync( done, message: "Working");
The client prints:
1/: Working 2/: Working 3/: Working Finished
Related recipes
- Serve resource URI templates in C#
- Serve prompt templates in C#
- Cancel a long-running server tool call in C#
The short answer
Inject RequestContext into a generated tool method and await ReportProgressAsync(progress, total, message). On the client, call the overload of CallToolAsync that accepts IProgress<ProgressNotificationParams>. OneModelContext adds a progress token to that request and routes matching notifications to the receiver.
Both sides matter. A handler can report progress correctly and still produce no visible updates when the caller did not ask for them. The full program includes a receiver as well as a handler, so it demonstrates the complete path rather than only sending notifications into an unobserved connection.
How do I report server tool progress in C#?
Measure completed work in units that mean something for the operation: processed documents, completed steps or rows examined. The example uses three short simulated steps. Each delay accepts the request’s cancellation token, and each completed step sends an increasing value with the same total.
RequestContext is filled in by the generated invoker; it is not a model-supplied argument in the tool’s JSON Schema. The tool signature rules also allow an injected CancellationToken. Keep progress reporting inside the lifetime of the request. It describes that call, not a background task that continues after a result is returned.
The final Finished line comes from the tool result. It is separate from the progress messages. Do not treat a progress value equal to the total as the returned result: the caller still needs to await CallToolAsync and handle its success or failure.
How does a client receive progress notifications?
The complete program passes a JsonElement containing an empty argument object and an InlineProgress receiver to the progress-aware overload. Its receiver writes each update directly. This keeps the demonstration’s console output in notification order without posting callbacks onto another synchronization context.
A graphical application should marshal updates to its own UI thread as needed. The receiver interface does not itself draw a progress bar. Store the latest completed value, optional total and message in whatever display model your application uses.
The protocol progress specification ties notifications to a token supplied in request metadata. OneModelContext creates the token when a non-null progress receiver is supplied. In the pinned implementation, RequestContext.ReportProgressAsync returns immediately without sending anything if no token is present. That is why adding only server code may appear to have no effect.
Can I report progress without a total?
Yes. The total is nullable, and the variation omits it. Report an increasing completed-work value rather than guessing a percentage. The specification requires progress to increase with each notification and allows the total to be unknown.
The example’s simple console formatter leaves the total blank, producing 1/: Working. A real display can instead show “1 completed” or an indeterminate indicator. Do not divide by a missing total or assume that a value of 1 always means 100 percent.
Notifications are optional status updates, not a durable job record. Send them at useful milestones rather than once per tiny loop iteration, and stop reporting when the operation ends. Use the eventual result or exception to decide the outcome; missing updates alone do not establish that a call failed.