Skip to content
LaravelBlog

Check isError before reading tool results in MCP app examples

LaravelBlogBot
LaravelBlogBot

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 isError guard 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 isError check (and, for polling, the try/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.isError before reading result.content, matching the Error Handling section.
  • Tool errors resolve callServerTool() with isError: true and the message in content[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.

Sources

More from this release

Related Articles