About synapse
Synapse is a skill for building React interfaces for existing MCP servers and serving them as MCP Apps resources. It guides developers through the SDK, host portability, and server integration.
Key features
- 01React UI bundled into one HTML file
- 02Synapse hooks and component library
- 03Host capability-aware behavior
- 04Tool bridge and resource integration
- 05Build and verification workflow
Use cases
- 01Adding an interactive UI to an MCP server
- 02Supporting Claude, ChatGPT, and NimbleBrain hosts
- 03Keeping UI data current after server writes
MCP support
The skill describes serving the bundled UI as an MCP resource and connecting it to existing server tools through the MCP Apps host bridge. It also covers host registration and capability handling.
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ nimblebraininc/ skills --skill synapseAfter installing, type/synapsein Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/gotchas.mdhost-contract.mdportability.mdDetails
From the project READMESynapse — build a UI for an MCP Server
Give an MCP server an interactive UI. The UI is a React app built to one inlined HTML file with @nimblebrain/synapse + Vite, served by the server as the MCP resource ui://<name>/main, and mounted by an MCP Apps host (Claude, ChatGPT, NimbleBrain) in a sandboxed iframe wired to a postMessage bridge. The UI calls the server's existing tools over that bridge — it is data-layer-agnostic and needs no special server framework. Every host speaks the same bridge; they differ in which capabilities they declare, and the SDK behaves predictably when one is missing. Read Portability below before you write components.
Target @nimblebrain/synapse@^0.20.0 (published on npm), with its peers @modelcontextprotocol/ext-apps@^1.7.5 and @modelcontextprotocol/sdk@^1.29.0. A caret range on 0.x does not cross a minor, so an app stays on its pin until someone bumps it deliberately. On NimbleBrain, 0.20.0 needs a host release that declares message, updateModelContext and the ai.nimblebrain/* extensions (nimblebrain#1239): on an earlier host, chat, model context, host actions and key forwarding do nothing and the file picker rejects. The package is the documentation — read its exported types before writing code. Its guides live at synapse.nimblebrain.ai.
Pre-flight — read the SDK's types (the real docs)
From the installed package (node_modules/@nimblebrain/synapse/dist/*.d.ts) or the public repo (github.com/NimbleBrainInc/synapse):
- the
reactentry —AppProviderand its hooks (useApp,useCallTool,useCallToolAsTask,useDataSync,useTheme,useHostContext,useModelContext,useSendMessage,useAction,useFileUpload,useToolResult,useToolInput,useResize). - the
uientry — the component library (AppFrame,ListDetailLayout,ListRow,Table,Badge,Prose,ConfirmDialog,tokens, …). Don't hand-roll styling. - the type exports —
ToolCallResult,Theme, theAppinterface (includinghostCapabilities),HostCapabilityError,hostSupports,NIMBLEBRAIN_EXTENSIONS.
Then read references/gotchas.md (non-obvious API facts that each save a debugging cycle) and references/host-contract.md (the NimbleBrain manifest declaration + the bridge). Skim these first; they're short and they're the difference between working and "why is every tool call returning unauthenticated."
Portability — this is an MCP app, not a NimbleBrain app
Claude, ChatGPT and NimbleBrain all speak MCP Apps. Each declares what it offers in its
ui/initialize answer (hostCapabilities), and every gated Synapse call checks that declaration
before it sends, then does one documented thing when the capability is missing (resize is never
gated, and useDataSync only listens):
- a request with an answer rejects with
HostCapabilityError, without sending —useCallTool(serverTools),readServerResource(serverResources),downloadFile,useCallToolAsTask(tasks), and the file picker; - a fire-and-forget call sends nothing —
useSendMessage(message),useModelContext(updateModelContext),useAction,forwardKeys;openLinkopens the URL itself; - a hook that waits on the host keeps its initial value —
useToolResult/useToolInputstaynull,useDataSyncnever fires where the host does not relaynotifications/resources/list_changed(or without your server announcing, step 5).
The portable subset is the handshake, theme and host context, tool input/results, callTool,
sendMessage, updateModelContext and resize. An app built on that behaves the same everywhere.
The NimbleBrain extensions are exactly three (synapse/action, synapse/request-file,
synapse/keydown), each used only where the host declares ai.nimblebrain/<name> in
hostCapabilities.experimental.
A no-op is still invisible to the user, so read the declaration and hide what the host can't do:
hostSupports(app, "requestFile"), app.hostCapabilities.message, app.supportsTasks.
<AppProvider> renders nothing until the handshake completes, so all of these are settled before
any component reads them — and where the handshake never returns (the built HTML opened directly),
nothing under the provider renders at all.
Per-hook table, the extension list, serving one app to Claude and ChatGPT, and the two connection
entry points: references/portability.md.
Process
-
Analyze the server — language (Python/FastMCP or TS), transport (stdio vs HTTP-native/edge-fronted), the tool list + return shapes, and deploy shape (
.mcpbbundle vs a container image — the container needs a Node build stage, step 7). -
Scaffold
ui/—package.json(react/react-dom^19,@nimblebrain/synapse@^0.20.0,@modelcontextprotocol/ext-apps@^1.7.5,@modelcontextprotocol/sdk@^1.29.0,vite,vite-plugin-singlefile,typescript; addmarked+dompurifyonly if you render markdown),vite.config.ts(react(),viteSingleFile(),synapseVite(),build.assetsInlineLimit: Infinity), a stricttsconfig.json,index.html, and.gitignore(node_modules/,dist/,.vite/). Commitpackage-lock.jsonso the build cannpm ci. -
Build
App.tsx—<AppProvider name="<server>" version="<version>">. One side-effect import goes in the Vite entry (main.tsx):import "@nimblebrain/synapse/ui/base"(the root-height chainAppFramefills — applied before first paint; gotcha M). Don't import fonts — the SDK ships none, and typography arrives from the host like every other theme value (gotcha N). Use the package'sAppFrameshell (withAppFrame.Body bleedhostingListDetailLayout/SidebarLayout) — never a hand-rolledheight:100vh(gotcha D). Drive data withuseCallTool(name)or a thinuseCall<T>()wrapper arounduseApp().callTool(name, args); re-read inuseDataSync(() => …), which fires when your server announces a write (step 5) — the callback names no tool, so the answer is always to re-read; theme withtokens/useTheme; push agent context withuseModelContext. Master lists useListRow, notTable(gotcha D — aTableoverflows a fixed-width rail and paints over the detail pane). -
Sanitize any rendered HTML — do not skip (stored-XSS). If you render server- or agent-authored markdown (notes, descriptions, research output) via
Prose/dangerouslySetInnerHTML, run it through DOMPurify first:DOMPurify.sanitize(marked.parse(md, { async: false }) as string). The iframe runs withallow-scriptsand ascript-src 'unsafe-inline'CSP, so sanitization — not the CSP — is the only thing stopping an injected<script>/onerrorfrom running with full tool-bridge authority (read/exfiltrate/mutate everything the tools can reach). Plain<Text>{value}</Text>is safe (React escapes). Full chain: gotcha K. -
Serve the UI as a resource, and announce writes.
@mcp.resource("ui://<name>/main")returning the builtui/dist/index.html, served as the spec's MIME typetext/html;profile=mcp-app(the default for aui://URI infastmcp3.4+; passmime_typeexplicitly anywhere else). Resolve the path via an env var (<APP>_UI_DIR) with a__file__-relative fallback (gotcha E — an installed package lands in site-packages, so a__file__-relativeui/distlookup misses). Make it a bare file read: no DB/auth session, identity-free HTML, all tenant data fetched at runtime through the bridge. Then make every tool that writes sendnotifications/resources/list_changedafter the write commits — that is the only thinguseDataSyncfires on, and it covers every writer (the agent, another view, a webhook):from fastmcp import Context from mcp.types import ResourceListChangedNotification @mcp.tool() async def save_item(item: Item, ctx: Context) -> dict: stored = store.save(item) await ctx.session.send_notification(ResourceListChangedNotification(), related_request_id=ctx.request_id) return storedrelated_request_idmakes it travel in the call's own response over HTTP. Announce once per write, not per row. Full guide:synapse.nimblebrain.ai/docs/guides/keep-ui-in-sync/. -
Register the app with its host. The bundle from steps 2–5 is the same either way; only registration differs. Targeting the NimbleBrain host — add
_meta["ai.nimblebrain/host"]tomanifest.json: oneplacements[]entry (slot: "sidebar.apps",resourceUri: "ui://<name>/main",route,label,icon). Full contract + options:references/host-contract.md. Targeting Claude or ChatGPT — the host renders the resource your tool names: bind it with_meta.ui.resourceUrion the tool, serve that one resource undertext/html;profile=mcp-app(never a second copy under another MIME), declareui.visibilityandui.csp, and — if the server needs sign-in —securitySchemesplus a401challenge. Python servers get all of it fromnimblebrain-synapse'sSynapseUI. Then runnpx @nimblebrain/synapse check --target claude,chatgpt <server-url>. Details:references/portability.md. -
Container-deployed servers — multi-stage build. A
.mcpbbundle getsui/distfrom release CI; a container image must build it: anode:22builder stage runsnpm ci && npm run build, then the runtimeCOPY --from=builder …/ui/dist(so the runtime stays Node-free) and sets the UI-dir env var. Strip theui/source from the runtime layer — onlydistships. -
Local preview —
cd ui && npm run dev→ open/__preview. For an edge-fronted server (identity from HTTP headers + a DB), the real server can't be driven over the stdio preview, so every call returnsunauthenticated— pointsynapseVite({ serverCmd })at a seeded stdio mock behind an env flag (gotcha F). -
Verify —
npx tsc --noEmitandnpm run build(Vite/esbuild won't type-check on its own). Then run the server project's own lint/format/test gate (e.g.make verify,ruff format --check), not just the UI type-check — a format-only diff will redden CI even when types pass. Toggle the preview between light and dark and eyeball every surface — theme-blind color passestsc/buildand only breaks in one mode. Everytokens.*is backed in both; your own vars are not (gotcha L). Confirm the server servesui://<name>/mainastext/html;profile=mcp-app, and that a write through the preview refreshes the view (the preview relays your server'slist_changed, gotcha F).
Out of scope
- Host-side rendering — the host already implements placement → iframe → bridge; you build the bundle + declare the placement, nothing more.
- Declarative host "commands"/toolbars — they don't exist. In-app buttons live inside the iframe and call tools via
useCallTool.