Reporting Progress and Logs
A tool that takes ten seconds looks exactly like a tool that's stuck, until it returns. this.progress() and this.notify() let a tool report while it works: how much is done, and anything worth knowing along the way. The client can show a progress bar, or a line in a log, and the person waiting knows the call is still alive.
You will learn
- How to report progress with
this.progress() - How to send log messages with
this.notify(), and which level to use - How a client asks for notifications under MCP 2026-07-28, and what the response looks like
- What happens when a client doesn't ask
- How to test progress and logs
A tool that goes quiet
This tool exports every ticket to a CSV file, one ticket at a time. Each ticket takes a moment:
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";
@Tool({
name: "export_tickets",
description: "Export every support ticket to tickets.csv. Takes a few seconds.",
inputSchema: {},
})
export class ExportTickets extends ToolContext {
async execute() {
const rows: string[] = [];
for (const ticket of tickets) {
rows.push(await exportRow(ticket));
}
return { file: "tickets.csv", rows: rows.length };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
With five tickets that's under a second. With five thousand, the user stares at a spinner for minutes and can't tell a slow export from a stuck one. Nothing about this tool is wrong, except that it says nothing until it's done.
Reporting progress
this.progress(progress, total, message) sends a progress notification to the client that made the call:
progressis how much is done so far. It should go up with every call; the MCP spec requires it.totalis how much there is in all, if you know it. Leave it out if you don't.messageis a short description of the current step. It's optional too.
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";
@Tool({
name: "export_tickets",
description: "Export every support ticket to tickets.csv. Takes a few seconds.",
inputSchema: {},
})
export class ExportTickets extends ToolContext {
async execute() {
const rows: string[] = [];
for (const [i, ticket] of tickets.entries()) {
rows.push(await exportRow(ticket));
await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`);
}
return { file: "tickets.csv", rows: rows.length };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The Call tab now shows a progress bar and five progress notifications above the result. A real client gets each one as it's sent, while the tool is still running. The Playground collects the whole response first, so here they appear together when the call ends.
Report after each real unit of work, like a ticket, a page or a file. Progress that jumps from 0 to 100% at the end doesn't tell anyone anything.
Sending log messages
Progress says how far along a tool is. this.notify(message, level) says what's happening, as a log message:
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";
@Tool({
name: "export_tickets",
description: "Export every support ticket to tickets.csv. Takes a few seconds.",
inputSchema: {},
})
export class ExportTickets extends ToolContext {
async execute() {
await this.notify(`Exporting ${tickets.length} tickets to tickets.csv`);
const rows: string[] = [];
for (const [i, ticket] of tickets.entries()) {
if (!ticket.title) await this.notify(`${ticket.id} has no title. It's exported with an empty one.`, "warning");
rows.push(await exportRow(ticket));
await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`);
}
await this.notify({ file: "tickets.csv", bytes: rows.join("\n").length }, "debug");
return { file: "tickets.csv", rows: rows.length };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Each call to notify() sends a notifications/message:
- A string becomes
data: { message: "..." }. An object is sent asdataas it is, for clients that read structured logs. levelis one of MCP's log levels, from least to most severe:"debug","info","notice","warning","error","critical","alert"and"emergency". It defaults to"info".- FrontMCP sets
loggerto the tool's name, so the client knows which tool said it.
The client chooses the lowest level it wants, and FrontMCP drops anything below it. The Call tab asks for "info" and up, which is why the debug message with the byte count isn't in the list above.
How a client asks for notifications
In MCP 2026-07-28, notifications belong to the request that caused them, and a client asks for them in that request's _meta:
- a
progressTokenasks for progress. The server copies the token into every progress notification, so a client with several calls in flight knows which one each update is about. io.modelcontextprotocol/logLevelasks for log messages at that level and above.
Open the Wire tab in the example above and expand the tools/call. The request carries both:
{
"method": "tools/call",
"params": {
"name": "export_tickets",
"arguments": {},
"_meta": {
"progressToken": "progress-1",
"io.modelcontextprotocol/logLevel": "info"
}
}
}
(The real request's _meta also holds the protocol version, client info and capabilities.) When a request asks for either, FrontMCP answers with Content-Type: text/event-stream instead of plain JSON. Each notification is an event, sent as it happens, and the last event is the result:
event: message
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","logger":"export_tickets","data":{"message":"Exporting 5 tickets to tickets.csv"}}}
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"progress-1","progress":1,"total":5,"message":"Exported T-1"}}
…
event: message
data: {"jsonrpc":"2.0","id":6,"result":{"content":[…],"structuredContent":{"file":"tickets.csv","rows":5}}}
The Wire tab labels this response SSE and shows each event before the final response. When the call ends, the stream ends with it.
A client in your own code asks the same way. One made with connect() passes { onProgress } as callTool()'s third argument, which sends a progress token with that call, and gets each update: see this.progress(). (Changed in 1.9.3: before, a connect() client had no way to ask.)
When the client doesn't ask
Not every client asks for notifications, and a client that asks for some calls may not ask for others. A tool has to work either way. This version puts what progress() returned into the result, so you can see it:
import { Tool, ToolContext } from "@frontmcp/sdk";
import { exportRow, tickets } from "./store";
@Tool({
name: "export_tickets",
description: "Export every support ticket to tickets.csv. Takes a few seconds.",
inputSchema: {},
})
export class ExportTickets extends ToolContext {
async execute() {
let reported = false;
for (const [i, ticket] of tickets.entries()) {
await exportRow(ticket);
reported = await this.progress(i + 1, tickets.length, `Exported ${ticket.id}`);
}
// Only here to show what progress() returned.
return { file: "tickets.csv", rows: tickets.length, progressReported: reported };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The first call asked for progress, so progressReported is true. Untick Stream progress & logs and call again: the response is plain JSON, with no notifications, and progressReported is false. The export itself is the same.
progress() and notify() return true when the notification was queued for the client, and false when the client didn't ask for it (or, for notify(), asked only for higher levels). They never throw for that reason, so you don't need to check before calling them. The test file does the same thing from code: mcp.raw.request() sends a request exactly as you write it, without a progressToken.
Testing progress and logs
The test client records the notifications that come back. mcp.notifications.collectProgress().all lists the progress updates, and mcp.notifications.collect().received lists every notification, logs included. In the Playground, each mcp.tools.call() asks for progress and for every log level, debug included:
import { test, expect } from "@frontmcp/testing";
test("reports progress once per ticket", async ({ mcp }) => {
const progress = mcp.notifications.collectProgress();
const result = await mcp.tools.call("export_tickets", {});
expect(result).toBeSuccessful();
expect(progress.all.map((p) => p.progress)).toEqual([1, 2, 3, 4, 5]);
expect(progress.all.every((p) => p.total === 5)).toBe(true);
});
type LogMessage = { level: string; logger?: string; data: { message?: string } };
test("warns about the ticket with no title", async ({ mcp }) => {
const notifications = mcp.notifications.collect();
await mcp.tools.call("export_tickets", {});
const warnings = notifications.received
.filter((n) => n.method === "notifications/message")
.map((n) => n.params as LogMessage)
.filter((log) => log.level === "warning");
expect(warnings).toHaveLength(1);
expect(warnings[0].data.message).toContain("T-2");
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Recap
this.progress(progress, total, message)reports how far along a tool is.progressshould go up with every call, andtotalandmessageare optional.this.notify(message, level)sends a log message. A string becomesdata.message, an object is sent asdata, andleveldefaults to"info".- Under 2026-07-28 a client asks per request, with a
progressTokenandio.modelcontextprotocol/logLevelin_meta. The response is then an event stream: notifications first, the result last. - A client that doesn't ask gets a plain JSON result.
progress()andnotify()returnfalse, and the call works the same. - Notifications are for the client and the person watching. Put what the model needs in the result.
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Challenge 1 of 3
Report progress while importing
import_tickets copies tickets from the old help desk, one at a time, and says nothing until it's done. Report progress after each ticket, with the total number of tickets.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
const imported: { id: string; title: string }[] = [];
@Tool({
name: "import_tickets",
description: "Import tickets from the old help desk. Takes about a second per 5 tickets.",
inputSchema: {
tickets: z.array(z.object({ id: z.string(), title: z.string() })).min(1).max(20),
},
})
export class ImportTickets extends ToolContext {
async execute({ tickets }: { tickets: { id: string; title: string }[] }) {
for (const ticket of tickets) {
await new Promise((resolve) => setTimeout(resolve, 100));
imported.push(ticket);
}
return { imported: tickets.length };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.