Check isError before reading tool results in MCP app examples
Contributed by lazerg
Part of the mcp v1.0.2 release
Pull request #362 is a documentation-only fix, but it repairs the first code most developers will ever copy from the MCP app skill reference. The quick start and polling examples read result.content without checking result.isError, so a server-side tool error was displayed as normal output in the quick start, and the polling example threw inside JSON.parse() on every 2-second tick. Both examples — along with the image example, the onToolResult example, and the app-resource stub — now guard on isError the same way the Error Handling section already does. Fixes #361. If you're upgrading from v1.0.1, there is nothing to migrate; the only action item is patching any app code you copied from the old examples.
Tool errors resolve, they don't reject
The root of the confusion is a subtlety of the MCP protocol: tool errors are reported in-band. When a tool returns Response::error('Monitor temporarily unavailable'), the client's callServerTool() call still resolves — it does not reject. The result simply comes back with isError: true, and the error message is placed in content[0].text as ordinary text content.
That means code like result.content[0]?.text ?? '' will happily read the error message and treat it as data. Transport-level failures (the connection drops, the payload is malformed) do reject the promise, so those were never the issue. The gap was specifically tool-level errors, and the reference's Error Handling section already handled them correctly — the other examples just hadn't caught up.
What the examples now look like
Five spots were updated: both quick-start variants in the app.md skill reference, the polling example, the image example, the app.onToolResult callback example, and stubs/mcp-app-resource.view.stub, which is used when scaffolding a new MCP app resource. The quick-start pattern now reads:
createMcpApp(async (app) => {
document.getElementById('run-btn').addEventListener('click', async () => {
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
const output = document.getElementById('output');
if (result.isError) {
output.textContent = `Error: ${result.content[0]?.text ?? 'Unknown error'}`;
return;
}
output.textContent = result.content[0]?.text ?? '';
});
});
The image example now early-returns when result.isError is set before building the data: URI, and the onToolResult example checks params.result.isError and wraps its JSON.parse() in a try/catch with a "Malformed tool result" fallback.
Why the polling example mattered most
The polling example was the worst offender because of how it fails. Its old body was:
const result = await app.callServerTool('get-monitor-data');
const data = JSON.parse(result.content[0]?.text ?? '{}');
document.getElementById('cpu').textContent = data.cpu;
When the underlying tool returned an error, content[0].text contained something like "Monitor temporarily unavailable" — not JSON — so JSON.parse() threw a SyntaxError. Because poll() is an async function invoked by setInterval(poll, 2000), that throw became an unhandled promise rejection, and you got a fresh one every two seconds for as long as the tool kept failing. The new version fails quietly and retries:
createMcpApp(async (app) => {
async function poll() {
try {
const result = await app.callServerTool('get-monitor-data');
if (result.isError) {
document.getElementById('cpu').textContent = result.content[0]?.text ?? 'Unavailable';
return;
}
const data = JSON.parse(result.content[0]?.text ?? '{}');
document.getElementById('cpu').textContent = data.cpu;
} catch {
// transport failure or malformed payload, retry on next tick
}
}
setInterval(poll, 2000);
});
A degraded tool now surfaces its message in the UI, a dropped transport doesn't flood the console with rejections, and the next tick naturally retries.
Upgrade impact from v1.0.1
There is no behavior change in the package itself — callServerTool(), onToolResult, and server-side Response::error() all behave exactly as before. Concretely:
- No code migration is required. Your existing servers and apps keep working unchanged.
- New scaffolds benefit automatically. The updated stub means any MCP app resource you generate going forward includes the
isErrorguard from the start. - Existing scaffolds keep the old code. If you generated an app resource on v1.0.1, the stub change doesn't retroactively edit your file — apply the guard by hand using the snippets above.
- Copied examples should be patched. If your client code was based on the old quick start or polling example, add the
isErrorcheck (and, for polling, thetry/catch) to match the corrected reference.
Server-side code is entirely untouched, so no tool changes are needed.
Takeaways
- Docs-only release: the MCP app skill reference and the app-resource stub now check
result.isErrorbefore readingresult.content, matching the Error Handling section. - Tool errors resolve
callServerTool()withisError: trueand the message incontent[0].text— always check the flag before parsing or rendering content. - The polling example additionally wraps each tick in
try/catch, so transport failures and malformed payloads no longer produce unhandled rejections every 2 seconds. - Upgrading from v1.0.1 requires no changes; only patch code you copied from the previous examples or scaffolded with the old stub.