MP-301a · Module 3
Partial Results & Cancellation
3 min read
Partial results are the 301-level answer to the binary success/failure model. In the real world, tools often produce useful output before they finish — a search that found 30 results before timing out, a bulk operation that processed 80 of 100 records before hitting an error, a data export that generated 3 of 5 requested reports. Returning nothing because the operation was not 100% complete wastes the work that was done and forces the LLM to start over.
Cancellation in MCP uses the JSON-RPC cancellation mechanism: the client sends a notifications/cancelled message with the request ID. Your server receives this via the SDK's cancellation callback. The challenge is propagating cancellation into your handler — if your tool is mid-database-query or mid-API-call when cancellation arrives, you need to abort the in-flight work and return whatever you have. AbortController is the standard mechanism: create one per tool call, pass its signal to fetch calls and database drivers, and check signal.aborted between processing steps.
// Cancellation-aware bulk processor with partial results
async function bulkProcess(
items: string[],
signal: AbortSignal,
): Promise<ToolResult> {
const processed: { id: string; status: string }[] = [];
const errors: { id: string; error: string }[] = [];
for (const item of items) {
// Check cancellation between items
if (signal.aborted) {
return packagePartialResult(processed, errors, items.length, "cancelled");
}
try {
await processItem(item, { signal }); // Pass signal to sub-operations
processed.push({ id: item, status: "ok" });
} catch (err) {
if (signal.aborted) {
return packagePartialResult(processed, errors, items.length, "cancelled");
}
errors.push({ id: item, error: (err as Error).message });
}
}
return packagePartialResult(processed, errors, items.length, "complete");
}
function packagePartialResult(
processed: { id: string; status: string }[],
errors: { id: string; error: string }[],
total: number,
reason: string,
) {
return {
content: [{ type: "text" as const, text: JSON.stringify({
status: reason,
total,
processed: processed.length,
failed: errors.length,
remaining: total - processed.length - errors.length,
results: processed,
errors: errors.length > 0 ? errors : undefined,
}, null, 2) }],
isError: reason !== "complete" && processed.length === 0,
};
}
- Accept AbortSignal in every handler Wire the MCP SDK's cancellation notification to an AbortController. Pass the signal to every handler so cancellation propagates.
- Check between processing steps In loops and sequential operations, check signal.aborted between steps. Do not wait until the end — the whole point of cancellation is to stop early.
- Package results immediately When cancellation or timeout fires, package whatever you have with a status field. Include counts (total, processed, remaining) so the LLM knows the coverage.