Skip to content

Call MCP tools with types

Calling an MCP tool over raw ext-apps is a pile of boilerplate: build a JSON-RPC envelope, generate a request ID, post it to the parent, track the pending promise, time it out, then dig the payload out of the content array and parse it. You write it once, then again for the next tool, untyped every time.

useCallTool<TOutput>(name) returns everything you need: a call function, the last data, an isPending flag, and an error. Pass the tool’s output type as the generic and data is typed for you. Wrap your tree in <AppProvider>.

import { useCallTool } from "@nimblebrain/synapse/react";
interface DocumentInfo {
id: string;
title: string;
}
function DocumentList() {
const { call, data, isPending, error } = useCallTool<DocumentInfo[]>("list_documents");
if (isPending) return <Spinner />;
if (error) return <ErrorBox error={error} />;
if (!data) return <button onClick={() => call({ limit: 10 })}>Load documents</button>;
return (
<ul>
{data.map((doc) => (
<li key={doc.id}>{doc.title}</li>
))}
</ul>
);
}

Outside React, call the tool on the App and read result.data:

const result = await app.callTool("list_documents", { limit: 10 });
console.log(result.data); // typed output

call(args?) sends the tools/call request, tracks it, waits for the host’s response, and parses the content array into typed data for you. isPending flips around the call so you can render a loading state, and a thrown or errored call lands in error instead of an unhandled rejection. Concurrent calls are guarded, so if the user triggers the same tool twice only the latest result is kept, and a stale earlier response cannot overwrite it.

A browser suspends a background tab, and a laptop lid closes. A call in flight when that happens is cut off. The NimbleBrain host answers it with JSON-RPC error -32001 whose data.suspendedMs says how long the page was suspended, so an app can tell its own sleep from a slow server. isSuspensionError(err) checks for it.

The tool may or may not have run, so the SDK never sends it again: the call rejects, and useCallTool puts the host’s answer in error. Re-read before retrying a write, and tell the user to look first:

import { isSuspensionError } from "@nimblebrain/synapse";
const { call, error } = useCallTool("archive_document");
if (isSuspensionError(error)) {
// The page slept through the call. Re-read, then offer to try again.
}

A read is safe to call again. A view that reads through useDataSync re-reads by itself when it is shown after a long hide (see keep the UI in sync).