# 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`.

```ts
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](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring. Pass an `onprogress` callback and the SDK puts the `progressToken` in `_meta` for you.

```ts
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.

```ts
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

::: warning 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](https://modelcontextprotocol.io/specification/draft/deprecated).
:::

Declare the `logging` capability when you construct the server.

```ts
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.

```ts
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.

```ts
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](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md).

## 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.

```ts
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.

```ts
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 Stop
```

The 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.

```ts
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.notify` sends `notifications/progress` when the request carried a `progressToken`; `progress` must increase on each one.
- `ctx.mcpReq.log(level, data)` sends `notifications/message` once the `logging` capability is declared; MCP logging is deprecated (SEP-2577).
- `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O.
