The voice agent was booking a service appointment. The customer had said yes to Tuesday at 10am. The agent called the booking tool. Then nothing. The customer waited. Four seconds. Eight seconds. Twelve seconds.
The session timed out at fifteen.
The customer hung up. The booking never completed from their perspective. The transcript showed a perfectly coherent conversation up to the tool call, then a dead end. The booking itself eventually succeeded -- the record showed up in the CRM two minutes later -- but by then the agent session was gone. Nobody told the agent. The customer thought the booking failed. They called back.
This is the synchronous MCP problem. Fast tools are fine in a blocking model. Slow tools -- CRM writes, booking systems, payment processors, document generators -- have always been a reliability gap. The tool runs longer than the session can wait. Something fails, silently, at exactly the worst moment.
The November 2025 MCP spec revision added Tasks to close this gap. Here's how it works and how to build with it.
Why synchronous calls break down in production
Synchronous MCP tool calls work fine for fast operations. A database lookup that returns in 50ms, a cache read that returns in 5ms, a string transformation that returns in 2ms -- all of these work well when the agent just blocks and waits. The user barely notices.
The problem is that CX agents don't only call fast tools. They call slow ones, and those slow ones are often the most important operations in the workflow.
Booking a service appointment requires a round-trip to a CRM or scheduling system. Average response time: 3-8 seconds. Processing a return requires writing to an order management system and sometimes triggering a warehouse workflow. Average response time: 5-12 seconds. Sending a transactional email and waiting for send confirmation: 2-6 seconds. Validating a customer's identity against a third-party ID verification service: 4-15 seconds.
These aren't edge cases. They're the core operations of most CX agent workflows. And in a synchronous model, every single one of them blocks the agent's execution thread until it returns. If the tool takes 12 seconds and your voice session timeout is 10 seconds, the tool wins and the session dies.
The workarounds developers use are predictable: generous session timeouts that hurt experience in the normal case, fire-and-forget calls that never check success, ad-hoc polling loops built with setInterval inside the agent. None of these are standardized, none interoperate across different agent frameworks, and all of them are fragile in the ways that matter in production.
// Every MCP tool call blocks the agent thread until it returns
const result = await mcpClient.callTool("create_booking", {
customerId: "C123",
serviceDate: "2026-06-15",
serviceTime: "10:00",
serviceType: "oil-change-60min",
});
// If this takes 12 seconds:
// - Voice session may have timed out at second 10
// - Customer has been listening to silence
// - If session dropped, this result goes nowhere
// - Customer calls back thinking it failedThat's the gap Tasks closes.
How MCP Tasks work
MCP Tasks introduce a standardized call-now, fetch-later pattern. The client calls the tool as usual. Instead of blocking until the work is done, the server returns a task handle immediately -- a taskId and an initial status of "working". The actual work continues in the background. The client checks in periodically (polling) or subscribes to status updates if the server supports push notifications. When the status changes to "completed", the client retrieves the result using the taskId.
The critical shift is that "call now" and "fetch later" are two distinct operations, separated in time. The agent doesn't have to wait for the tool to finish. It can continue the conversation -- "I've started your booking, give me just a moment" -- while the booking system works in the background.
// Request with async hint -- server returns task handle in ~50ms
const task = await mcpClient.callTool(
"create_booking",
{
customerId: "C123",
serviceDate: "2026-06-15",
serviceTime: "10:00",
serviceType: "oil-change-60min",
},
{ preferAsync: true }
);
// task = { taskId: "task_abc123", status: "working" }
// Returned in ~50ms, not 8 seconds
// Agent can speak to the customer while the booking runs
await voice.say("I'm confirming your appointment now -- just a moment.");
// Poll for completion with the returned taskId
const result = await mcpClient.tasks.waitForCompletion(task.taskId, {
timeoutMs: 30000,
pollIntervalMs: 500,
});
if (result.status === "completed") {
await voice.say(
`You're all set. Appointment confirmed for Tuesday, June 15 at 10am. ` +
`Your confirmation code is ${result.output.confirmationCode}.`
);
}From the customer's perspective: the agent immediately acknowledges, a short pause while the booking runs, then a clean confirmation. No silence stretching past the session timeout. No dead end.
The task lifecycle in detail
Tasks move through a defined set of states, and each state has a specific handling pattern.
working is the initial state after task creation. The server has acknowledged the task and is actively processing. The client's job is to check in periodically (polling) or wait for a push notification (if the server supports subscriptions). The client shouldn't retry or create a new task -- one is already running.
input_required is where Tasks go beyond simple fire-and-forget, and it's the state that makes async tools genuinely useful for CX. If the tool hits a blocking decision mid-execution that needs user input, it can pause and surface the decision back through the agent. A booking tool that discovers the requested slot is taken can enter input_required state with a payload describing the conflict and available alternatives.
The agent gets this, relays it to the customer naturally ("That time is taken -- would 11am or 2pm work?"), collects their choice, and resumes the task with the updated input. The task continues exactly where it left off. This makes async tasks interactive rather than just asynchronous.
async function waitForTaskCompletion(
mcpClient: McpClient,
taskId: string,
agent: ConversationAgent
) {
let taskResult = await mcpClient.tasks.poll(taskId);
while (
taskResult.status === "working" ||
taskResult.status === "input_required"
) {
if (taskResult.status === "input_required") {
// Task paused and needs user input to continue
const userChoice = await agent.askUser({
message: taskResult.inputPrompt,
options: taskResult.inputOptions,
});
// Resume the task with what the user said
await mcpClient.tasks.resume(taskId, { userInput: userChoice });
}
await sleep(500);
taskResult = await mcpClient.tasks.poll(taskId);
}
return taskResult;
}completed means the task finished successfully. The result is available via the taskId. The result has the same shape as what a synchronous call would have returned -- the tool's contract doesn't change, just the timing model.
failed means the task ended in an error. The failure payload includes an error type and message. The agent can decide whether to retry, escalate to a human, or offer the customer an alternative path. Failed tasks are not automatically retried; that's a client-side decision, and the 2026 spec now gives you standardized policies for it (covered below).
cancelled means the task was explicitly stopped -- typically because the user ended the session or the agent determined the task was no longer needed.
Building a server that supports Tasks
Adding Tasks support to an existing MCP server is targeted. You don't need to convert every tool -- only the ones that run slow operations. Here's what the server-side implementation looks like:
import { McpServer } from "@modelcontextprotocol/sdk/server";
import type { TaskContext } from "@modelcontextprotocol/sdk/types";
import { z } from "zod";
const server = new McpServer({ name: "booking-service", version: "2.0.0" });
server.tool(
"create_booking",
{
description: "Create a service appointment for a customer",
inputSchema: z.object({
customerId: z.string(),
serviceDate: z.string(),
serviceTime: z.string(),
serviceType: z.string(),
}),
// Declare Tasks support -- clients can choose sync or async
annotations: { async: true },
},
async (input, { task }: { task?: TaskContext }) => {
// If called without async hint, fall back to synchronous
if (!task) {
return await runBookingSync(input);
}
// Async path: report progress, handle user decisions
await task.progress("Checking availability...");
const availability = await crmClient.checkAvailability({
date: input.serviceDate,
time: input.serviceTime,
type: input.serviceType,
});
if (!availability.slotOpen) {
// Pause and ask the agent to get user input
return await task.requireInput({
message: "Requested slot is not available.",
inputPrompt: "Which alternative works for you?",
inputOptions: availability.nextOpenSlots.map(s => s.label),
});
}
await task.progress("Slot confirmed, creating booking...");
const booking = await crmClient.createBooking({
...input,
slotId: availability.slotId,
});
return task.complete({
bookingId: booking.id,
confirmationCode: booking.confirmationCode,
confirmedDate: booking.date,
confirmedTime: booking.time,
});
}
);The additions over a standard synchronous tool are: the annotations: { async: true } declaration that advertises Tasks support to clients, and the task context parameter that provides progress(), requireInput(), and complete() methods. Clients that don't support Tasks can still call this tool synchronously -- the if (!task) fallback handles them.
For connecting task-aware tools to CX agents, Chanl's MCP integration wires up the task lifecycle across the agent session, including surfacing input_required states back through voice or chat channels. The tools documentation covers the full integration pattern.
What the 2026 roadmap added for production
The November 2025 spec introduced Tasks as an experimental extension. The 2026 MCP roadmap, published in Q1 2026, refined the primitive in two areas that matter specifically for production reliability.
Retry semantics. The spec now defines who decides to retry a failed task, what backoff strategy is required, and how many retries are allowed before the task state moves to permanently failed. Before this, every client team implemented their own retry logic with incompatible behavior. One client retried immediately; another used exponential backoff; another didn't retry at all. Incompatible retry policies compound when a single workflow spans multiple MCP servers.
// 2026 spec: retry policy declared by the server tool
server.tool("create_booking", {
// ...
retryPolicy: {
maxAttempts: 3,
backoff: "exponential",
baseDelayMs: 1000,
// Retry on transient errors only -- not on business logic failures
retryOn: ["transient_error", "timeout"],
noRetryOn: ["slot_conflict", "validation_error", "customer_not_found"],
},
});
// Client respects this policy rather than implementing its own
// Consistent behavior across any MCP client that connects to this serverExpiry policies. After a task completes, how long does the result stay available? Before the 2026 update, this was undefined -- some servers kept results indefinitely, others purged them in minutes. Clients had no way to know if a result would still be there when they polled.
The 2026 spec adds a standardized expiresAt timestamp on completed tasks and a well-defined task_expired response type for clients that poll after expiry. For CX agents, expiry matters when a voice call drops and reconnects -- the client needs to know whether the task result from before the drop is still retrievable, or whether the operation needs to restart.
const result = await mcpClient.tasks.poll(taskId);
if (result.status === "task_expired") {
// Result is gone -- restart the operation
const newTask = await mcpClient.callTool("create_booking", originalInput, {
preferAsync: true,
});
return waitForTaskCompletion(mcpClient, newTask.taskId, agent);
}What Tasks mean for CX agent architecture
Tasks change what's possible for multi-step CX workflows, and the clearest gain is parallel execution.
In a synchronous architecture, a complete booking workflow -- create the appointment, update the CRM record, send a confirmation email, log the interaction -- runs those four operations serially. Total time: the sum of all four tool durations. If each takes 4-6 seconds, you're looking at 16-24 seconds minimum.
With Tasks, you kick all four off simultaneously and wait for all to complete. Total time: the slowest single operation. If the CRM update is the bottleneck at 6 seconds, the whole workflow completes in 6 seconds instead of 24.
// Kick off all four operations simultaneously
const [bookingTask, crmTask, emailTask, logTask] = await Promise.all([
mcpClient.callTool("create_booking", bookingInput, { preferAsync: true }),
mcpClient.callTool("update_crm_record", crmInput, { preferAsync: true }),
mcpClient.callTool("send_confirmation_email", emailInput, { preferAsync: true }),
mcpClient.callTool("log_interaction", logInput, { preferAsync: true }),
]);
// Wait for all to complete (or fail)
const results = await Promise.all([
mcpClient.tasks.waitForCompletion(bookingTask.taskId, { timeoutMs: 30000 }),
mcpClient.tasks.waitForCompletion(crmTask.taskId, { timeoutMs: 30000 }),
mcpClient.tasks.waitForCompletion(emailTask.taskId, { timeoutMs: 30000 }),
mcpClient.tasks.waitForCompletion(logTask.taskId, { timeoutMs: 30000 }),
]);
// Handle any failures individually
const failures = results.filter(r => r.status === "failed");
if (failures.length > 0) {
await agent.escalate(`${failures.length} background operation(s) failed`);
}Parallel tasks are only possible because each operation returns immediately with a handle. In a synchronous model, you can't start the second operation until the first finishes. Tasks break that constraint.
For teams already thinking about latency reduction across multi-tool workflows, speculative tool calling pairs well with Tasks: speculative calling reduces time-to-first-result for fast tools, while Tasks eliminate blocking for the slow ones. The two techniques address different parts of the same latency problem.
A deeper look at why the underlying MCP protocol exists -- and the tool calling fragmentation it solves -- is in why MCP exists: tool calling shouldn't need adapter code. Tasks build on that foundation and extend it into production-grade async, completing the picture of what a real production agent needs from its tool layer.
For teams testing async workflows before shipping, Chanl's scenario testing supports multi-step conversation flows with task simulation. You can test the full booking workflow including input_required states, retries, and concurrent tasks in controlled scenarios before any of it touches production traffic.
The synchronous bottleneck in CX agents isn't a model limitation or a framework quirk. It's a protocol gap, and Tasks close it. The question now is which agent architectures are designed to take advantage of it.
Test your async agent workflows before they go live
Chanl's scenario testing lets you run multi-step conversation flows with task simulation -- including input_required states, concurrent tasks, and retry paths. No production traffic required.
Try Chanl free- MCP Roadmap 2026 -- Official Priorities for Model Context Protocol Scalability and AI Agents
- MCP Async Tasks: Building long-running workflows for AI Agents, WorkOS
- Architecting the Asynchronous Agent: A Guide to MCP Tasks, Medium
- MCP Gets Tasks: A Game-Changer for Long-Running AI Operations, DEV Community
- Model Context Protocol Roadmap, modelcontextprotocol.io
- Everything your team needs to know about MCP in 2026, WorkOS
- MCP Enterprise Readiness: How the 2025-11-25 Spec Closes the Production Gap
Co-founder
Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.
Learn Agentic AI
Weekly. Patterns for shipping agents that work. MCP, scorecards, regression tests, prompts, model comparisons.
