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,
  };
}
  1. Accept AbortSignal in every handler Wire the MCP SDK's cancellation notification to an AbortController. Pass the signal to every handler so cancellation propagates.
  2. 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.
  3. 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.