Request-Scoped Streaming
Notifications delivered on the response stream of the request that caused them.
When a modern request carries _meta.progressToken or _meta["io.modelcontextprotocol/logLevel"], its response is an SSE stream instead of a JSON body. Progress and log messages then travel with the request they belong to — no separate channel, no correlation to reconstruct.
const importTool = defineMcpTool({
name: "import",
handler: async (event) => {
const mcp = event.context.mcp!;
for (let i = 1; i <= 10; i++) {
mcp.progress?.(i, 10, `row ${i}`);
mcp.log?.("info", { row: i }, "importer");
await importRow(i);
}
return { content: [{ type: "text", text: "Imported" }] };
},
});#The Helpers
| Call | Emits | Silent when |
|---|---|---|
progress(progress, total?, msg?) | notifications/progress | no progressToken on the request |
log(level, data, logger?) | notifications/message | client asked for no level, or a coarser one |
notify(method, params?) | any notification | the request has no stream |
log() gates on the requested level exactly as the spec requires: a client asking for warning never receives your debug lines. The eight levels are debug, info, notice, warning, error, critical, alert, emergency.
Note
A request only gets a stream when the client opted in. Without progressToken or logLevel in _meta the response is a plain JSON body and progress() / log() / notify() silently do nothing — there is nowhere to deliver a notification and the spec offers no out-of-band path. This is by design, not a failure: write handlers that work either way.
#Cancellation
Closing the response stream aborts the request. The transport signal is linked into event.context.mcp.signal, so the same check that handles legacy notifications/cancelled covers it:
if (event.context.mcp?.signal?.aborted) return;This is the modern cancellation mechanism — notifications/cancelled is not expected over HTTP.
#What Does Not Go Here
Change notifications — notifications/tools/list_changed, notifications/resources/updated — belong on a subscriptions/listen stream, not on a request-scoped one. A request-scoped stream lives and dies with its request; nothing outside that request should be published on it.
#Errors Mid-Stream
Once the stream is open the HTTP status is already 200, so a handler that fails afterwards is reported as an error frame on the stream rather than an HTTP error. The same message rules apply: throw McpJsonRpcError to say something specific, or the error collapses to -32603 "Internal error".