Skip to content

Local Development

Workflow What it gives you When to use
Synapse Preview Standalone preview with tool calls and HMR Building a new app, iterating on UI
bun run dev –app Full NimbleBrain platform with HMR Testing data sync, agent interactions, multi-app
bun run dev + reload Full platform, manual restart Quick tool changes, no UI build step
Tools only MCP server in Claude Desktop or any client Testing tools without UI
Section titled “Synapse Preview (recommended for UI development)”

The fastest way to develop an MCP app with a UI. No NimbleBrain platform needed.

5173/__preview
cd your-app/ui
npm run dev

The Synapse Vite plugin (synapseVite() in your vite.config.ts) handles everything:

  • Reads ../manifest.json to get your app name and server command
  • Spawns the MCP server as a child process (stdio mode)
  • Serves a preview host page at /__preview that iframes your app
  • Proxies tool calls from the iframe to the MCP server
  • HMR works — edit .tsx files, see changes instantly
Feature Supported
Tool calls (proxied to MCP server) Yes
ext-apps handshake (Synapse initializes) Yes
Dark/light theme toggle Yes
NimbleBrain theme tokens Yes
Console logging (chat, actions, state) Yes
Data sync (agent tool calls) No — use bun run dev
Multi-app navigation No — use bun run dev

The plugin reads ../manifest.json automatically. Zero config for most apps:

ui/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
import { synapseVite } from "@nimblebrain/synapse/vite";
export default defineConfig({
plugins: [react(), viteSingleFile(), synapseVite()],
build: { outDir: "dist", assetsInlineLimit: Infinity },
});

Override if needed:

synapseVite({
appName: "my-app", // override manifest name
serverCmd: "uv run python -m server", // override derived command
manifest: "../custom-manifest.json", // custom manifest path
preview: false, // disable preview (build-only)
})
Terminal window
cd nimblebrain # the platform repo
bun run dev --app /path/to/your-app/ui

Run your MCP server yourself over HTTP, and point the workspace at it. The platform connects to remote MCP servers and never spawns one, so a dev loop is the same shape as production: a URL your workspace holds.

Terminal window
# Terminal 1 — your server, over streamable HTTP
uv run python -m mcp_myapp.server --http --port 8765

Add it to the dev workspace’s workspace.json, and allow the loopback host (the SSRF guard refuses it otherwise):

<workDir>/workspaces/<wsId>/workspace.json
{
"connectors": [
{ "url": "http://localhost:8765/mcp", "serverName": "myapp" }
]
}
<workDir>/nimblebrain.json
{ "allowInsecureRemotes": true }

This gives you everything — data sync, agent interactions, theme injection, keyboard forwarding, multi-app navigation — plus Vite HMR on your UI.

Terminal window
bun run dev

The platform connects to the url in the workspace config. UI changes require:

  1. Rebuild the UI: cd ui && npm run build
  2. Restart your MCP server so it serves the rebuilt ui:// resource
  3. Hard-refresh the browser (Cmd+Shift+R)

Test your MCP server in any client:

Terminal window
# Python
uv run python -m mcp_myapp.server
# TypeScript
node dist/index.js --stdio

Add to Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):

{
"mcpServers": {
"myapp": {
"command": "uv",
"args": ["run", "python", "-m", "mcp_myapp.server"],
"cwd": "/path/to/your-app"
}
}
}
  1. Create the MCP server (Python or TypeScript) with tools and a ui:// resource:

    @mcp.resource("ui://myapp/main")
    def app_ui() -> str:
    return load_ui() # reads ui/dist/index.html
  2. Create the UI project:

    Terminal window
    mkdir ui && cd ui
    npm init -y
    npm install react react-dom
    npm install -D @nimblebrain/synapse @vitejs/plugin-react vite vite-plugin-singlefile typescript @types/react @types/react-dom
  3. Configure Vite for single-file output:

    ui/vite.config.ts
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import { viteSingleFile } from "vite-plugin-singlefile";
    export default defineConfig({
    plugins: [react(), viteSingleFile()],
    build: { outDir: "dist", assetsInlineLimit: Infinity },
    });
  4. Write your React app with Synapse hooks:

    ui/src/App.tsx
    import { AppProvider, useCallTool, useDataSync } from "@nimblebrain/synapse/react";
    function MyApp() {
    const { call, isPending, data } = useCallTool("my_tool");
    useDataSync(() => { /* refetch when the server announces a write */ });
    return <button onClick={() => call({ arg: "value" })} disabled={isPending}>Go</button>;
    }
    export function App() {
    return (
    <AppProvider name="myapp" version="1.0.0">
    <MyApp />
    </AppProvider>
    );
    }
  5. Build and preview:

    Terminal window
    cd ui && npm run build && cd ..
    npx @nimblebrain/synapse preview --server "uv run uvicorn mcp_myapp.server:app --port 8001" --ui ./ui

See the Hello World example for a complete walkthrough.

When you build your app for distribution (via mcpb-pack), dependencies are bundled into a deps/ directory. If your project has a deps/ directory, the platform uses it as the primary import path, placing it before src/ on PYTHONPATH.

This means edits to files in src/ are invisible to the running server — Python imports the stale copy from deps/ instead.

Fix: Delete the bundled copy of your package from deps/ during local development:

Terminal window
rm -rf deps/mcp_my_app

Then restart the dev server so the change takes effect (bun run dev restarts the API on source changes).

Add this temporarily to see all bridge traffic:

window.addEventListener('message', (event) => {
console.log('[bridge]', event.data?.method, event.data);
});

In the Synapse Preview, ui/message, ai.nimblebrain/action, and ui/update-model-context messages are logged to the browser console automatically.

Your app runs inside a sandboxed iframe:

Allowed Not allowed
JavaScript execution HTML form submission
postMessage to host Top-level navigation
Opening popups (new tabs) alert() / confirm() / prompt()
Inline styles and scripts Nested iframes

Network requests from the iframe are blocked by default (connect-src 'none'). If your app needs external API access, declare domains in your manifest:

{
"csp": {
"connectDomains": ["https://api.example.com"]
}
}