# How to use MCP Tasks and Elicitations in Claude Code

> Build a local MCP server with background tasks and confirmation forms in Claude Code. Learn how polling, cancellation, and reconnecting work.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-10-26 | Topics: [AI](https://flaviocopes.com/tags/ai/) | Canonical: https://flaviocopes.com/claude-code-mcp-tasks-elicitations/

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](https://flaviocopes.com/courses/mcp/).

## 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](https://code.claude.com/docs/en/mcp#requirements-for-mcp-tasks) 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:

```bash
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.

```js
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:

```bash
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:

```bash
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:

```text
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:

```text
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:

```text
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](https://flaviocopes.com/mcp-2026-07-28-stateless/) 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:

```json
{
  "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.

```mermaid
sequenceDiagram
  accTitle: MCP task with an elicitation
  accDescr: Claude Code calls tools/call, gets a task back, polls tasks/get, receives input_required, answers with tasks/update, and polls again for the completed result.
  participant C as Claude Code
  participant S as MCP server
  C->>S: tools/call
  S-->>C: task ID
  C->>S: tasks/get
  S-->>C: input_required with confirmation form
  C->>S: tasks/update with your answer
  S-->>C: acknowledgment
  C->>S: tasks/get
  S-->>C: completed with tool result
```

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](https://flaviocopes.com/courses/build-with-mcp/) 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`:

```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](https://code.claude.com/docs/en/mcp#mcp-tasks-that-run-on-the-server)
- [Claude Code docs: respond to MCP elicitation requests](https://code.claude.com/docs/en/mcp#respond-to-mcp-elicitation-requests)
- [MCP Tasks extension overview](https://modelcontextprotocol.io/extensions/tasks/overview)
- [@upstash/mcp-toolkit on npm](https://www.npmjs.com/package/@upstash/mcp-toolkit)
- [Upstash: MCP Tasks and Elicitations in Claude Code](https://upstash.com/blog/claude-code-mcp-tasks)
