How to use MCP Tasks and Elicitations in Claude Code
By Flavio Copes
Build a local MCP server with background tasks and confirmation forms in Claude Code. Learn how polling, cancellation, and reconnecting work.
Suppose you ask Claude Code to build your website. It calls a tool on an MCP server, a program that gives Claude access to external services. The build takes five minutes. You want Claude to review a file while it runs, then bring you the build result when it’s ready.
A normal tool call keeps a request open until the work finishes. That request can time out or lose its connection. Claude Code can move the call into the background, but following a remote job after you close and reopen the session needs more than that.
MCP Tasks give the job an ID. The server accepts the work and returns that ID right away. Claude can get on with your file review while Claude Code checks the build’s progress. If you disconnect, you can resume the same session and collect the result, provided the server has kept the task.
Now suppose the next step is deploying the build. You want to review it before anything goes live. Elicitations let the server show you a question, such as “Deploy this build to production?”, and use your answer to decide whether to continue. A task can wait for that answer partway through the job.
These features are useful when a tool takes long enough that you want to keep working, or when it needs a decision from you before continuing.
Let’s build a local server that demonstrates both. It will simulate a build and a deployment using timers, so you can try the workflow without changing a website.
If MCP is new to you, start with the free MCP course.
What tasks change
Claude Code can already move an ordinary slow MCP call into the background after two minutes. That frees up the conversation, but the server still holds the original request open.
An MCP task returns a handle as soon as the server accepts the job. Later requests check its progress and retrieve its result. This also lets a client reconnect after a disconnect.
| Behavior | Ordinary tool call moved to the background | MCP task |
|---|---|---|
| Original request | Stays open until the tool finishes | Returns a task ID |
| Progress | Comes from the ongoing call | Retrieved through task status checks |
| After exiting Claude Code | The client stops following the call | Resume the same session to follow a remote task |
The server still needs somewhere to run the work. Returning a task ID doesn’t keep a serverless function alive or preserve an in-memory job after a server restart.
What you need
Use an up-to-date Claude Code CLI. MCP Tasks are in public beta and require protocol revision 2026-07-28 on both sides. We’ll use the current TypeScript SDK and Upstash’s toolkit for the server.
This walkthrough uses an interactive terminal session. Calls from subagents, one-shot claude -p runs, the VS Code extension and the desktop app don’t get MCP tasks. Agent SDK and streaming JSON sessions have additional configuration requirements; the Claude Code requirements cover those.
Tasks also depend on Anthropic enabling the feature for your session. Third-party providers such as Amazon Bedrock, disabled feature-flag fetching, or forcing the legacy MCP runtime can prevent task support.
The @upstash/mcp-toolkit package handles tasks/get, tasks/update and tasks/cancel for us. You can use it locally without an Upstash account.
Set up the project
Create a folder and install the dependencies:
mkdir site-tools && cd site-tools
npm init -y
npm pkg set type=module
npm install @upstash/mcp-toolkit @modelcontextprotocol/server zod hono @hono/node-server
Hono and @hono/node-server are there only to serve the handler over HTTP on Node. The MCP handler is a standard (request) => Response function, so Next.js, Bun or any other framework works too.
A server with a slow tool and a tool that asks
Save the following as server.js. The build_site tool waits about 30 seconds and reports progress at each step. The deploy tool asks for confirmation, then waits five seconds. Both return simulated results.
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'
import { McpTasks } from '@upstash/mcp-toolkit/tasks'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import * as z from 'zod'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const tasks = new McpTasks({ principal: () => 'local' })
tasks.registerTask(
'build_site',
{
description: 'Builds the site. Takes about 30 seconds.',
inputSchema: z.object({ branch: z.string() }),
},
async ({ branch }, task) => {
for (const step of ['Installing', 'Compiling', 'Optimizing images']) {
if (await task.isCancelled()) return {}
await task.update(step)
await sleep(10_000)
}
return { content: [{ type: 'text', text: `Simulated build of ${branch} succeeded.` }] }
}
)
tasks.registerTask(
'deploy',
{
description: 'Simulates deploying a branch after the user confirms.',
inputSchema: z.object({ branch: z.string() }),
},
async ({ branch }, task) => {
const answer = await task.elicit({
message: `Simulate deploying ${branch} to production?`,
requestedSchema: z.object({ confirm: z.boolean() }),
})
if (answer.action === 'accept' && answer.content.confirm) {
await task.update('Uploading')
await sleep(5_000)
return { content: [{ type: 'text', text: `Simulated deploy of ${branch} completed.` }] }
}
return { content: [{ type: 'text', text: 'Deploy skipped.' }] }
}
)
const mcp = createMcpHandler(() => {
const server = new McpServer({ name: 'site-tools', version: '1.0.0' })
tasks.attach(server)
return server
})
const app = new Hono()
app.post('/mcp', (c) => mcp.fetch(c.req.raw))
serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 3000 })
console.log('MCP server on http://localhost:3000/mcp')
Run it and leave this terminal open:
node server.js
task.update('Compiling') sets the status message. Claude Code shows the latest one in /tasks.
task.isCancelled() is how you stop early. Cancelling is cooperative, so the code only stops where you check it.
task.elicit() pauses the task. The task moves to input_required until you answer, and answer.action is 'accept', 'decline' or 'cancel'. When it’s 'accept', answer.content is validated against the schema you passed.
principal determines who owns each task. A fixed string works for this single-user demo, bound to the loopback interface. A shared server needs authentication and a principal derived from the authenticated user, so people cannot read or answer each other’s tasks.
Connect it to Claude Code
Add the server over HTTP:
claude mcp add --transport http site-tools http://localhost:3000/mcp
In another terminal, start claude from a project containing a package.json. Check /mcp to confirm that site-tools connected, then ask:
Build the main branch with site-tools, then tell me what's in package.json while it runs
With task support enabled, the tool call returns before the simulated build finishes. The footer shows 1 MCP task, and Claude goes on reading package.json. When the build finishes, the transcript gets a line like this and Claude receives the result:
MCP task <server task id> (site-tools/build_site) completed.
Type /tasks to see the running task and its latest status. To test cancellation, start another build, select it in /tasks, and press x. The handler checks for cancellation between steps, so it can take up to ten seconds to stop. Cancellation cannot undo work a tool has already performed.
Now try the other tool:
Deploy main with site-tools
Claude Code opens a form asking Simulate deploying main to production? with a confirm checkbox. Accept with the box checked to get the simulated success result. Accept with it unchecked to skip the deploy. Declining or pressing Esc also returns Deploy skipped. in this example. The handler only proceeds when the action is accept and confirm is true.
Be careful with /tasks: while it’s open, the elicitation dialog stays hidden. Close it to answer.
What happens on the wire
You don’t need this to use the toolkit, but it helps when debugging.
On the current protocol, Claude Code sends its capabilities with every request in _meta. See the stateless MCP guide for how this differs from older MCP connections. When the Tasks extension is listed there, the server answers a tools/call with a task instead of a result:
{
"content": [],
"resultType": "task",
"taskId": "4dd24bcb-6172-4c63-b59f-c45426dbb2ef",
"status": "working",
"statusMessage": "Queued",
"createdAt": "2026-10-26T07:52:57.652Z",
"lastUpdatedAt": "2026-10-26T07:52:57.652Z",
"ttlMs": 86400000,
"pollIntervalMs": 2000
}
Claude Code then calls tasks/get with the taskId every pollIntervalMs milliseconds. While deploy waits for you, tasks/get returns status: "input_required" with an inputRequests object holding an elicitation/create request. Claude Code shows the form and sends your answer back with tasks/update. When the status reaches completed, the result field holds what the tool would have returned synchronously.
With this toolkit, a client that doesn’t declare the extension still works. It gets the task ID in a normal tool result, and the model checks on it with the task_status and task_cancel tools that tasks.attach() registers.
Before you deploy it
This server keeps tasks in memory. A restart loses them, and with more than one instance, a tasks/get can hit an instance that never saw the task.
The toolkit can store tasks in Upstash Redis and run them through QStash or Upstash Workflow instead. For elicitations, use the in-memory backend shown here or Workflow. The QStash dispatcher runs each task in one function invocation and cannot wait for an answer. Workflow can suspend while waiting and resume when input arrives.
For a real deployment tool, put the actual upload after the confirmation check and make retries safe. A retry after a timeout should not create a second deployment or charge someone twice. A confirmation form collects an answer; your server still enforces authorization.
I would use this for a remote build or report export that takes long enough to interrupt other work. For a quick database lookup, an ordinary tool result is enough. The free Build with MCP course covers building and securing a server beyond this demo.
If it doesn’t behave as expected
| Symptom | What to check |
|---|---|
| Claude waits for the full build | Check the session requirements above. A fallback client may need to call task_status itself. |
| A task is waiting but no form appears | Close /tasks; the elicitation dialog stays hidden while it is open. |
| The server no longer recognizes a task ID | This demo loses tasks on restart. Check whether the work happened before starting it again. |
| A remote task doesn’t return after reconnecting | Resume the session that started it and keep the server name and connection settings unchanged. Check /mcp for connection or sign-in problems. |
Exiting Claude Code does not cancel a remote task. Stop it in /tasks before leaving if you want to request cancellation. A local stdio server, by contrast, normally stops when Claude Code exits. Our HTTP demo runs in a separate terminal and lasts only as long as that process stays alive.
To make Claude wait for every MCP call, set this in your Claude Code settings.json:
{
"env": {
"CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS": "0"
}
}
CLAUDE_CODE_DISABLE_MCP_TASK_BACKGROUND=1 also makes Claude wait initially, but ordinary automatic backgrounding can still move a long call after two minutes. Use the setting above when you want it to keep waiting.
Sources
- Claude Code docs: MCP tasks that run on the server
- Claude Code docs: respond to MCP elicitation requests
- MCP Tasks extension overview
- @upstash/mcp-toolkit on npm
- Upstash: MCP Tasks and Elicitations in Claude Code
Want me to talk about your product? You can sponsor this site.
Related posts about ai: