I maintain an MCP server that generates music. One call takes anywhere from 40 seconds to three minutes, because there is a model rendering audio on the other end.
That does not fit the shape MCP tools are usually written in: call it, get an answer, move on. Everything about the transport assumes the answer is close by. It is worth writing down what actually breaks when it isn't, because "my tool is slow" turns out to be three separate problems wearing one coat.
What breaks
The obvious first version is a single tool that kicks off the job and awaits the result.
server.registerTool('generate_music', { /* ... */ }, async (args) => {
const task = await lacuna.music.generations.create(args)
const done = await waitUntilReady(task.id) // three minutes later
return { content: [{ type: 'text', text: JSON.stringify(done) }] }
})
1. You do not own the timeout. The MCP client does. Claude Desktop, Claude Code, Cursor and the rest each pick their own tool-call deadline, and none of them ask you what yours is. A tool that usually returns in 90 seconds and occasionally takes 200 will work on your machine and fail on someone else's, which is the worst possible failure distribution to debug.
2. If you hand the polling to the model, the model quits. The obvious fix is to return a pending task immediately and expose a get_generation tool so the agent can check on it. The agent will check on it. Twice. Maybe three times. Then it decides the job is wedged and tells the user "this seems to be taking a while, would you like me to keep checking?" — which is a reasonable thing for a helpful assistant to say and a terrible thing for a job that had 40 seconds left. You have converted a slow tool into an unreliable one.
3. Polling burns the context window. Every poll puts a full task object back in the transcript. Twenty polls of a JSON blob is real budget, spent entirely on the word pending.
Three tools instead of one
What ended up working is splitting the lifecycle across three tools, so the server owns the waiting loop and the model owns only the decisions.
// 1. Start it. Returns immediately with a pending task.
server.registerTool('generate_music', {
description:
'Create an AI music generation task. Returns immediately with a `pending` ' +
'task; use `wait_for_generation` or `get_generation` to retrieve the ' +
'finished tracks.',
inputSchema: { style: z.string(), title: z.string(), /* ... */ },
}, async (args) => {
const task = await lacuna.music.generations.create(args)
return { content: [{ type: 'text', text: format(task) }] }
})
// 2. Check once. For when the agent wants to interleave other work.
server.registerTool('get_generation', {
description: 'Retrieve the current state of a music generation task by id.',
inputSchema: { id: z.string() },
}, async ({ id }) => { /* ... */ })
// 3. Block until terminal. One tool call, one result in the transcript.
server.registerTool('wait_for_generation', {
description:
'Poll a generation task until it reaches a terminal state (`ready` or ' +
'`failed`) or the timeout elapses. Returns the final task object ' +
'including audio URLs on success.',
inputSchema: {
id: z.string(),
poll_interval_seconds: z.number().int().positive().optional(),
timeout_seconds: z.number().int().positive().optional(),
},
}, async ({ id, poll_interval_seconds, timeout_seconds }) => {
const task = await lacuna.music.generations.waitFor(id, {
pollInterval: (poll_interval_seconds ?? 5) * 1000,
timeout: (timeout_seconds ?? 600) * 1000,
})
return { content: [{ type: 'text', text: format(task) }] }
})
The split looks redundant — wait_for_generation is just get_generation in a loop — but the redundancy is the point. The loop runs inside one tool call, so the transcript gets one pending and one final result instead of twenty. And because poll_interval_seconds and timeout_seconds are inputs rather than constants, an agent that knows it is doing a short instrumental clip can ask for a tighter deadline than one rendering a full track.
get_generation stays because sometimes the agent genuinely should not block: it started three generations and wants to report on whichever lands first, or the user asked a question mid-render. Give it both and let it choose.
The description field is a prompt
This is the part I underestimated. In MCP, a tool's description is not documentation for humans — it is the only instruction the model gets about how your tools compose.
Compare:
Create a music generation task.
with what actually shipped:
Create an AI music generation task. Returns immediately with a
pendingtask; usewait_for_generationorget_generationto retrieve the finished tracks.
The second one is a two-hop plan written into the schema. Without that sentence, models call generate_music, get a pending object back, and confidently tell the user their song is ready — because the return value did technically look like a success. With it, the follow-up call happens on its own.
Write tool descriptions as if the reader has never seen the other tools and will never read your README. That is precisely the situation.
Errors are content, not exceptions
An exception thrown out of a tool handler becomes a protocol-level error, which the client surfaces as "the tool failed." The model gets nothing it can act on.
The alternative is to catch everything and return it as content with isError: true:
function toToolError(err: unknown) {
let message: string
if (err instanceof APIError) {
message = `Lacuna API error: HTTP ${err.status} ${err.code}: ${err.message}`
} else if (err instanceof LacunaError) {
message = `Lacuna error: ${err.message}`
} else if (err instanceof Error) {
message = err.message
} else {
message = String(err)
}
return { content: [{ type: 'text', text: message }], isError: true }
}
Now HTTP 402 insufficient_credits reaches the model as readable text, and it can tell the user to top up instead of retrying the same call four times. Same for HTTP 400 on a bad style string — the model rewrites the prompt and tries again, unassisted. Structured, human-readable error text is the cheapest agent-recovery mechanism there is.
Defaults over options
The wait_for_generation defaults are 5 seconds and 600 seconds. Both are deliberate.
A 5-second poll interval against a job that averages 90 seconds is ~18 requests — nothing for the API, and tight enough that the result feels prompt. The 600-second ceiling is well past the p99, so a timeout genuinely means something is wrong rather than "we were unlucky." The parameters are exposed because the agent occasionally knows better than the default, but the defaults are chosen so that never having to touch them is the normal case.
If you are designing something similar: pick the timeout from your own latency distribution, not from a round number. The p99 is the only input that matters.
Recap
For any MCP tool that outlives a request/response cycle:
- Return the handle immediately. Never make the client's unknown timeout your problem.
- Own the waiting loop server-side. One tool call, one transcript entry.
- Keep a single-check tool too. Blocking is a choice the agent should get to make.
- Put the composition plan in the description. It is the only prompt you control.
- Return errors as content. Models recover from text, not from stack traces.
The server this came from is lacuna-mcp, MIT-licensed, part of a small toolkit (SDK, CLI, MCP server) built on the public API behind Lacuna — an AI Song Generator and AI Lyrics Generator I work on. The whole server is 177 lines; the API docs cover the endpoints underneath it.
Curious how other people handle this. If you have shipped an MCP server where a tool routinely runs into the minutes — video, builds, long scrapes — I would like to know whether you landed on the same split or something better.
Top comments (2)
The lifecycle split is useful, but
wait_for_generationstill inherits the exact client-owned deadline described in problem #1. A ten-minute server timeout does not help if the MCP client kills the tool call at 60 or 120 seconds.I’d make wait bounded and resumable: accept a maximum server-held interval, return
pendingplusretry_afterand the same durable handle before the client deadline, and let the next call resume without creating another generation. Progress notifications can improve UX where the client supports them, but durable job state remains the compatibility layer.The start call also needs an idempotency key so a client timeout/retry cannot render and bill twice. Bind job handles to the caller/tenant, propagate cancellation where the provider supports it, use jittered backoff, and return compact typed states (
pending,ready,failed,cancelled,expired) rather than the full task object on every check.The split I like here is making the long job addressable instead of pretending the tool call is the job. I would also keep the first response deliberately boring, with a job id, expected next poll time, and a terminal-state schema. That gives clients something stable to resume after they drop the call.