Logging, progress, and cancellation
Every handler receives a context as its second argument; the request-scoped helpers — progress, logging, and the cancellation signal — live on ctx.mcpReq.
Report progress from a handler
A client that wants progress puts a progressToken in the request's _meta. Read it from ctx.mcpReq._meta and send each update with ctx.mcpReq.notify.
server.registerTool(
'process-files',
{
description: 'Process files with progress updates',
inputSchema: z.object({ files: z.array(z.string()) })
},
async ({ files }, ctx) => {
const progressToken = ctx.mcpReq._meta?.progressToken;
for (let i = 0; i < files.length; i++) {
// ... process files[i] ...
if (progressToken !== undefined) {
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: { progressToken, progress: i + 1, total: files.length, message: `Processed ${files[i]}` }
});
}
}
return { content: [{ type: 'text', text: `Processed ${files.length} files` }] };
}
);Every call on this page comes from an in-memory Client connected to this server — Test a server shows that wiring. Pass an onprogress callback and the SDK puts the progressToken in _meta for you.
const result = await client.callTool(
{ name: 'process-files', arguments: { files: ['a.csv', 'b.csv', 'c.csv'] } },
{ onprogress: update => console.log(update) }
);
console.log(result.content);The callback fires once per file, then the call resolves:
{ progress: 1, total: 3, message: 'Processed a.csv' }
{ progress: 2, total: 3, message: 'Processed b.csv' }
{ progress: 3, total: 3, message: 'Processed c.csv' }
[ { type: 'text', text: 'Processed 3 files' } ]Skip progress when the client did not ask
Drop onprogress and the same request arrives with no progressToken, so the guard in the handler sends nothing.
const quiet = await client.callTool({ name: 'process-files', arguments: { files: ['d.csv', 'e.csv'] } });
console.log(quiet.content);Only the result comes back:
[ { type: 'text', text: 'Processed 2 files' } ]progress must increase on every notification for the same token; total and message are optional.
Log to the client
Deprecated — SEP-2577
Log to stderr (stdio servers) or use OpenTelemetry instead. MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the deprecated features registry.
Declare the logging capability when you construct the server.
const server = new McpServer({ name: 'file-processor', version: '1.0.0' }, { capabilities: { logging: {} } });ctx.mcpReq.log(level, data) then sends a notifications/message from inside any handler — data is any JSON value.
server.registerTool(
'validate-records',
{
description: 'Validate records before import',
inputSchema: z.object({ records: z.array(z.string()) })
},
async ({ records }, ctx) => {
await ctx.mcpReq.log('info', `Validating ${records.length} records`);
const invalid = records.filter(record => !record.endsWith('.csv'));
if (invalid.length > 0) {
await ctx.mcpReq.log('warning', { invalid });
}
return { content: [{ type: 'text', text: `${records.length - invalid.length} of ${records.length} records are valid` }] };
}
);The connected client surfaces each one through its notifications/message handler.
client.setNotificationHandler('notifications/message', notification => {
console.log(notification.params.level, notification.params.data);
});Calling validate-records with one bad record delivers both log notifications before the result:
info Validating 2 records
warning { invalid: [ 'b.txt' ] }
[ { type: 'text', text: '1 of 2 records are valid' } ]How the client's log level reaches ctx.mcpReq.log differs by protocol era — see Protocol versions.
Stop work when the request is cancelled
ctx.mcpReq.signal is an AbortSignal. The SDK aborts it when the client sends notifications/cancelled for the request, and when the connection closes — check it before each unit of work.
server.registerTool(
'scan-archive',
{
description: 'Scan every page of the archive',
inputSchema: z.object({ pages: z.number().int() })
},
async ({ pages }, ctx) => {
let scanned = 0;
for (let page = 0; page < pages; page++) {
if (ctx.mcpReq.signal.aborted) {
console.error(`Stopped after ${scanned} of ${pages} pages: ${ctx.mcpReq.signal.reason}`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100)); // ... scan one page ...
scanned++;
}
return { content: [{ type: 'text', text: `Scanned ${scanned} pages` }] };
}
);Cancel from the client by aborting the signal you pass to the call.
const controller = new AbortController();
const scan = client.callTool({ name: 'scan-archive', arguments: { pages: 40 } }, { signal: controller.signal });
// the end user clicks Stop while the scan runs
setTimeout(() => controller.abort('the end user clicked Stop'), 5);
await scan.catch(error => console.log(String(error)));The call rejects on the client and the handler stops at its next check; the abort reason travels in the notification and comes out as ctx.mcpReq.signal.reason:
SdkError: the end user clicked Stop
Stopped after 1 of 40 pages: the end user clicked StopThe first line is the client's rejection, the second is the handler's console.error on the server. The SDK never sends a response for a cancelled request and discards whatever the handler still returns.
Pass the signal to your own I/O
Hand the same signal to fetch — or any API that accepts an AbortSignal — and cancellation propagates into the work the handler started.
const SOURCE_URLS = {
readme: 'https://example.com/sources/readme.md',
changelog: 'https://example.com/sources/changelog.md'
};
server.registerTool(
'fetch-source',
{
description: 'Download one of the known source files',
inputSchema: z.object({ source: z.enum(['readme', 'changelog']) })
},
async ({ source }, ctx) => {
const response = await fetch(SOURCE_URLS[source], { signal: ctx.mcpReq.signal });
return { content: [{ type: 'text', text: await response.text() }] };
}
);On cancellation fetch rejects mid-download and the handler unwinds with it, so no work outlives the request.
WARNING
Resolve an identifier against a fixed list, as fetch-source does. A tool that fetches a caller-supplied URL lets any connected client drive requests from your server's network position (server-side request forgery).
Recap
- Every handler receives a context as its second argument; the request-scoped helpers live on
ctx.mcpReq. ctx.mcpReq.notifysendsnotifications/progresswhen the request carried aprogressToken;progressmust increase on each one.ctx.mcpReq.log(level, data)sendsnotifications/messageonce theloggingcapability is declared; MCP logging is deprecated (SEP-2577).ctx.mcpReq.signalaborts on cancellation and disconnect — check it in long loops and forward it to your own I/O.