---
title: Every screen in this SaaS app is an MCP App
description: At AGNTCon + MCPCon North America, NimbleBrain's Mathew Goldsborough showed a task app that looks like any SaaS dashboard, except every panel is an MCP App composed at runtime. Here's the host side he built, placements, facets, lifecycle hooks and shared theme tokens, read against the open-source code.
date: 2026-10-26
tags: MCP Apps, MCP, AI agents, agentic web
---

Mathew Goldsborough put a task app on the screen: a to-do list, a sidebar, a chat panel wedged in on the right. It looked like any SaaS dashboard. Then he told the room that everything on it was an MCP server or an MCP App, composed at runtime. His company, NimbleBrain, builds the open-source host that holds those pieces together, and his talk was about that host: how an app finds its place on the screen, shows a count without a model call, learns it was installed, and ends up looking like one product.

It was the last talk of day one at AGNTCon + MCPCon North America in San Jose, on October 22. [Watch it on the livestream from 7:12:34](https://youtu.be/iNV5-YkPHW4?t=25954). Goldsborough is NimbleBrain's founder and CEO, and an AAIF ambassador, the only one based in Hawaii, so he gets to open with aloha. My [note on Liad Yosef's talk](/notes/websites-lose-the-front-door.html) covered MCP Apps from the client side: a seat map coming back inside your assistant. This is the other half, and I read it against NimbleBrain's code and docs, which are public.

<figure class="fd-fig nb nb-reveal" aria-labelledby="nb1-cap" data-desc="A task app that looks like a normal SaaS dashboard: a sidebar with Conversations, Files and Tasks, then an Apps group with To-dos, People and Memory; a to-do list in the main area with an Add task button; a chat panel on the right where the user asks what's next on the list. The figure alternates between what you see and what it is. In the second state each region carries a tag: the sidebar is host views plus app placements, the main area is the to-do server's ui:// resource in an iframe whose buttons are tool calls, and the chat is the agent loop with skills loaded over MCP.">
<p class="ea-now nb-say2" aria-hidden="true"><span>What you see: a SaaS app</span><span>What it is: MCP Apps in slots</span></p>
<div class="nb-win">
<p class="nb-win-bar"><i></i><i></i><i></i><span>Workspace · Ops</span></p>
<div class="nb-win-body">
<div class="nb-side nb-zone">
<ul class="nb-side-list">
<li class="nb-side-h">Workspace</li>
<li>Conversations</li>
<li>Files</li>
<li>Tasks</li>
<li class="nb-side-h">Apps</li>
<li class="nb-side-on">To-dos</li>
<li>People</li>
<li>Memory</li>
</ul>
<p class="nb-tag">Host views, then app placements</p>
</div>
<div class="nb-main nb-zone">
<p class="nb-main-h">To-dos</p>
<ul class="nb-todo">
<li><i></i>Review outbound drafts</li>
<li><i></i>Renew the sending domain</li>
<li class="nb-todo-done"><i></i>Sync the CRM</li>
</ul>
<p class="nb-btn">+ Add task</p>
<p class="nb-tag">ui://todos in an iframe. Buttons are tool calls</p>
</div>
<div class="nb-chat nb-zone">
<p class="nb-chat-h">Chat</p>
<p class="nb-msg nb-msg-me">What's next on my list?</p>
<p class="nb-msg">Review outbound drafts.</p>
<p class="nb-tag">The agent loop, skills over MCP</p>
</div>
</div>
</div>
<figcaption id="nb1-cap">The reveal, redrawn. The layout follows his demo; the to-do items are mine.</figcaption>
</figure>

## MCP is a package format now

His framing: every platform got a packaging format, and a composition layer around it. Executables on the desktop. WAR files for Java web apps, "if you were fortunate or unfortunate enough". Debian packages for Linux. Now MCP, which he says only recently matured enough to carry everything a real product needs.

He counts three pieces in the package, and calls them the triforce, for the Zelda fans. Tools, the capabilities a server hands the model. MCP Apps, rich interactive interfaces delivered over MCP. And skills over MCP, the know-how that loads with the tools; NimbleBrain rolled its own crude version before the extension existed. Add a `server.json` and a couple of extensions, and you have something you can ship and compose.

The quick demo shows why that matters. Ask a to-do server "what's next on my list?" and an HTML widget renders in an iframe, not a paragraph of text. Skills come back with it. Every button you click is a tool call.

<figure class="fd-fig nb nb-pack" aria-labelledby="nb2-cap" data-desc="Packaging formats in order: executables for the desktop, WAR files for Java web apps, Debian packages for Linux, and now MCP for agents and whole apps. Under them, the three pieces Goldsborough calls the triforce, each drawn as one triangle of the Zelda symbol: tools, what the server lets the model do; MCP Apps, interfaces rendered in an iframe where every button is a tool call; and skills, the know-how loaded over MCP. A server.json plus a couple of extensions holds all three.">
<ol class="nb-formats" aria-label="Packaging formats">
<li><b>.exe</b><span>The desktop</span></li>
<li><b>.war</b><span>Java web apps</span></li>
<li><b>.deb</b><span>Linux</span></li>
<li class="nb-formats-now"><b>MCP</b><span>Agents, and now whole apps</span></li>
</ol>
<ol class="mp-trio">
<li><svg class="mp-grow-art nb-tri" viewBox="0 0 120 104" aria-hidden="true"><path class="nb-tri-on" d="M60,4 L88,52 L32,52 Z"/><path d="M32,52 L60,100 L4,100 Z"/><path d="M88,52 L116,100 L60,100 Z"/></svg><p class="mp-trio-n"><b>1</b>Tools</p><p class="mp-trio-what">What the server lets the model do</p></li>
<li><svg class="mp-grow-art nb-tri" viewBox="0 0 120 104" aria-hidden="true"><path d="M60,4 L88,52 L32,52 Z"/><path class="nb-tri-on" d="M32,52 L60,100 L4,100 Z"/><path d="M88,52 L116,100 L60,100 Z"/></svg><p class="mp-trio-n"><b>2</b>Apps</p><p class="mp-trio-what">An interface in an iframe; every button is a tool call</p></li>
<li><svg class="mp-grow-art nb-tri" viewBox="0 0 120 104" aria-hidden="true"><path d="M60,4 L88,52 L32,52 Z"/><path d="M32,52 L60,100 L4,100 Z"/><path class="nb-tri-on" d="M88,52 L116,100 L60,100 Z"/></svg><p class="mp-trio-n"><b>3</b>Skills</p><p class="mp-trio-what">The know-how, loaded over MCP</p></li>
</ol>
<p class="mp-strip"><b>The package</b><span>A server.json and a couple of extensions hold all three.</span></p>
<figcaption id="nb2-cap">The history slide, then his triforce: one triangle per piece.</figcaption>
</figure>

## Chat is someone else's sandbox

The catch is that all of it lives in a chat. The package is yours and the server is yours, but you don't own the runtime. His picture: you walk into a sandbox with your toys, and someone tells you how to play, where to play, how big the walls are and how much sand you get.

Building your own host fixes that, and NimbleBrain has done it. But then you write the same plumbing every time. And the spec itself is built for one host talking to one server. There's little in it for one MCP server talking to another.

There's an audience problem too. Everybody's vibe coding in Claude Code and building their own harness, he said, but business users still want a dashboard, a button, a report, a graph. So NimbleBrain built the other half.

<figure class="fd-fig nb nb-sandbox" aria-labelledby="nb3-cap" data-desc="Two ways to run an MCP App today, both with a cost. In a chat host, it's their sandbox: your package and your server, but the host decides where the app renders, how big it is and what it can reach, and the spec covers one host talking to one server, not servers talking to each other. In your own host, you own the runtime, but you build the same plumbing every time: identity, files, conversations and tool routing again for every product. NimbleBrain's answer is to build that host once, as open source.">
<div class="mp-two-row">
<div class="mp-two-card">
<p class="mp-two-who">In a chat host</p>
<p class="mp-two-name">Their sandbox</p>
<svg class="mp-two-art" viewBox="0 0 300 100" aria-hidden="true"><rect class="nb-pit" x="40" y="14" width="220" height="76"/><rect class="nb-pit-sand" x="48" y="58" width="204" height="24"/><rect class="nb-pit-app" x="118" y="30" width="64" height="40"/><rect class="nb-pit-bar" x="118" y="30" width="64" height="8"/></svg>
<p class="mp-two-line">Your package, your server</p>
<p class="mp-two-line">Their walls: where it renders, how big, what it reaches</p>
<p class="mp-two-line">One host to one server, no server to server</p>
</div>
<p class="mp-two-vs">or</p>
<div class="mp-two-card">
<p class="mp-two-who">In your own host</p>
<p class="mp-two-name">Same plumbing, again</p>
<svg class="mp-two-art" viewBox="0 0 300 100" aria-hidden="true"><g class="nb-pipe"><rect class="nb-pipe-app" x="38" y="12" width="56" height="20"/><rect x="38" y="38" width="56" height="12"/><rect x="38" y="56" width="56" height="12"/><rect x="38" y="74" width="56" height="12"/></g><g class="nb-pipe"><rect class="nb-pipe-app" x="122" y="12" width="56" height="20"/><rect x="122" y="38" width="56" height="12"/><rect x="122" y="56" width="56" height="12"/><rect x="122" y="74" width="56" height="12"/></g><g class="nb-pipe"><rect class="nb-pipe-app" x="206" y="12" width="56" height="20"/><rect x="206" y="38" width="56" height="12"/><rect x="206" y="56" width="56" height="12"/><rect x="206" y="74" width="56" height="12"/></g></svg>
<p class="mp-two-line">You own the runtime</p>
<p class="mp-two-line">Identity, files, conversations, routing: rebuilt per product</p>
<p class="mp-two-line">Business users still get a chat box</p>
</div>
</div>
<p class="mp-strip"><b>NimbleBrain</b><span>Build the host once, as open source, and let every server use it.</span></p>
<figcaption id="nb3-cap">His two problems side by side. The sandbox is his picture; the plumbing stacks are mine.</figcaption>
</figure>

## The other half is a host

NimbleBrain boots into an empty shell with extension points: places in the web UI where an MCP App can render. Built-in apps come first (conversations, files, scheduled tasks), and installed apps show up next to them. One app or two hundred. Click one and its MCP App renders in the main area. You're not in chat anymore, it's a full SaaS screen, and the buttons aren't REST calls. They're MCP tool calls. His thesis, said with a grin: REST is dead, you can do everything over MCP.

Slide the chat out and the same app runs with an agent. You see the tool calls and the skills loading, and the agent can act. Or skip the web UI and connect your own MCP client, because the runtime is an MCP server too.

Under the shell sit what he calls host primitives, the things every server needs and shouldn't build itself: files, conversations, tool routing, identity. Everything splits into workspaces. Finance gets Sage and Stripe, engineering gets GitHub and Linear, and you keep a private one. Parts swap out. A customer with its own memory in a vector store plugged that in, people on his team who like Obsidian plugged Obsidian in, and Salesforce or HubSpot is a connector choice.

It runs anywhere: on-prem, a private cloud, their managed service. That puts it next to the internal APIs and the Postgres nobody has touched in twenty years. They put a small MCP broker in front of that, and one runtime reaches both the public cloud and the legacy system.

The repo backs this up. It's Apache 2.0, runs on Bun, ships as two container images, and `docker compose up` gives you the web UI on port 27246. The agent loop runs until the model stops calling tools, capped at 10 iterations by default and 25 at most.

<figure class="fd-fig nb nb-stackfig" aria-labelledby="nb4-cap" data-desc="NimbleBrain's stack as four layers. At the top, the front doors: the web shell, or your own MCP client such as a coding agent or harness. Below, the host primitives every server shares: files, conversations, tool routing, identity and workspaces. Below that, the MCP spec: tools, resources, apps and skills. At the bottom, the servers: NimbleBrain's own (web search, memory, a people CRM, an outbound app), yours, any public one, and a small broker in front of a twenty-year-old Postgres. Workspaces split the connectors: finance has Sage and Stripe, engineering has GitHub and Linear, and a private workspace has its own GitHub. It runs on-prem, in a private cloud or as a managed service, with any OpenAI-compatible model.">
<div class="nb-stack">
<div class="nb-layer nb-layer-door"><p class="nb-layer-k">Front doors</p><p class="nb-chips"><span>The web shell</span><span>Your MCP client</span><span>Coding agents, harnesses</span></p></div>
<div class="nb-layer nb-layer-host"><p class="nb-layer-k">Host primitives</p><p class="nb-chips"><span>Files</span><span>Conversations</span><span>Tool routing</span><span>Identity</span><span>Workspaces</span></p></div>
<div class="nb-layer"><p class="nb-layer-k">The MCP spec</p><p class="nb-chips"><span>Tools</span><span>Resources</span><span>Apps</span><span>Skills</span></p></div>
<div class="nb-layer nb-layer-srv"><p class="nb-layer-k">Servers</p><p class="nb-chips"><span>Theirs: search, memory, CRM, outbound</span><span>Yours</span><span>Any public one</span><span>A broker to a 20-year-old Postgres</span></p></div>
</div>
<ul class="nb-ws" aria-label="Workspaces">
<li><b>Finance</b><span>Sage · Stripe</span></li>
<li><b>Engineering</b><span>GitHub · Linear</span></li>
<li><b>Mine</b><span>GitHub</span></li>
</ul>
<p class="mp-strip"><b>Runs</b><span>On-prem, a private cloud or their managed service, with any OpenAI-compatible model.</span></p>
<figcaption id="nb4-cap">His architecture slide as layers, with the workspace example under it.</figcaption>
</figure>

## Placements: the app picks its slot

So how does an app end up in the sidebar? It asks. NimbleBrain defined a host extension under its reverse domain, `ai.nimblebrain/host`, and an app's manifest lists placements: a slot, a label, a route, and the `ui://` resource to render there.

```json
"ai.nimblebrain/host": {
  "placements": [
    { "slot": "sidebar.apps", "resourceUri": "ui://dashboard",
      "label": "Tasks", "route": "tasks" },
    { "slot": "settings", "resourceUri": "ui://settings" }
  ]
}
```

The host reads that at startup. It knows nothing about the server, only the interface. When you click Tasks, it loads that resource into an iframe in the main area, beside the docked chat, at a URL like `/w/ops/app/tasks`. The docs list the slots: an entry under Apps in the sidebar, a full page with no nav entry, a section on the connector's settings page, and a pinned item at the foot of the mobile drawer. The sidebar shows the first four apps, then a View all link. If two connectors claim the same route, the first one keeps it, and the host logs who holds it.

A business that wants a different layout can swap the template around the slots and keep the same apps.

<figure class="fd-fig nb nb-slots" aria-labelledby="nb5-cap" data-desc="How a placement becomes a screen, in four steps that light in turn. One: the host reads the manifest at startup, with two placements, a sidebar.apps entry labelled Tasks with the route tasks and the resource ui://dashboard, and a settings placement with ui://settings. Two: the Tasks entry appears under Apps in the sidebar. Three: a click opens /w/ops/app/tasks and the host renders ui://dashboard in an iframe in the main area, beside the docked chat. Four: ui://settings renders on the connector's settings page.">
<p class="ea-now ea-say4" aria-hidden="true"><span>1 · The host reads the manifest</span><span>2 · Tasks gets a sidebar slot</span><span>3 · A click renders the app</span><span>4 · Settings get their own page</span></p>
<div class="nb-slots-row">
<div class="nb-code nb-b1">
<span class="nb-cl">"placements": [</span>
<span class="nb-cl nb-b2">  { "slot": "sidebar.apps",</span>
<span class="nb-cl nb-b3">    "resourceUri": "ui://dashboard",</span>
<span class="nb-cl nb-b2">    "label": "Tasks",</span>
<span class="nb-cl nb-b3">    "route": "tasks" },</span>
<span class="nb-cl nb-b4">  { "slot": "settings",</span>
<span class="nb-cl nb-b4">    "resourceUri": "ui://settings" }</span>
<span class="nb-cl">]</span>
</div>
<div class="nb-shell">
<p class="nb-shell-url nb-b3">/w/ops/app/tasks</p>
<div class="nb-shell-body">
<ul class="nb-shell-side">
<li>Conversations</li>
<li>Files</li>
<li class="nb-shell-h">Apps</li>
<li class="nb-shell-entry nb-b2">Tasks</li>
</ul>
<div class="nb-shell-main nb-b3"><b>ui://dashboard</b><span>In an iframe, beside the docked chat</span></div>
</div>
<div class="nb-shell-set nb-b4"><b>Settings › Connectors › Tasks</b><span>ui://settings</span></div>
</div>
</div>
<figcaption id="nb5-cap">The manifest drives the shell. The slot names and the route shape are from NimbleBrain's docs.</figcaption>
</figure>

## Facets: a count without asking the model

This is the part I'd steal first. A dashboard needs numbers: tasks blocked, drafts waiting for review. You could ask the model. "Every time you ask the LLM, you spend more and more tokens," he said, so facets skip it.

A facet is an ordinary MCP resource with a marker in its `_meta`. The host lists the server's resources, spots the marker, reads that resource, and gets back one number:

```json
{ "uri": "acme://facets/drafts-awaiting-review",
  "title": "Drafts awaiting review",
  "_meta": { "ai.nimblebrain/facets": { "level": "warning" } } }

→ resources/read →  { "count": 3 }
```

The host shows it as one row, "3 Drafts awaiting review", in a Needs attention panel on the workspace overview, and a click opens the app. The docs pin down the rest. The level (critical, warning or info) sets the colour and the order. A zero shows nothing. A read slower than five seconds shows the row as unavailable without breaking the others. Counts are cached for 60 seconds. And the title and count are treated as untrusted data that never reach a model.

<figure class="fd-fig nb nb-facet" aria-labelledby="nb6-cap" data-desc="The facet path in four steps that light in turn. One: the host sends resources/list to the server. Two: it spots a resource whose _meta carries the ai.nimblebrain/facets marker with the level warning. Three: it sends resources/read and gets back a count of 3. Four: the workspace overview shows the row 3 Drafts awaiting review. Off to the side, the model is struck out: it is not on this path, so no tokens are spent. Under it, a Needs attention panel with one row per level: 2 tasks blocked as critical, 3 drafts awaiting review as warning, 1 reply received as info.">
<p class="ea-now ea-say4" aria-hidden="true"><span>1 · List the resources</span><span>2 · Spot the marker</span><span>3 · Read one number</span><span>4 · Render the row</span></p>
<div class="nb-facet-row">
<ol class="nb-flow">
<li class="nb-b1"><span class="nb-flow-who">Host → server</span><code>resources/list</code></li>
<li class="nb-b2"><span class="nb-flow-who">A resource carries</span><code>"ai.nimblebrain/facets": { "level": "warning" }</code></li>
<li class="nb-b3"><span class="nb-flow-who">Host → server</span><code>resources/read → { "count": 3 }</code></li>
<li class="nb-b4"><span class="nb-flow-who">Overview</span><b>3 Drafts awaiting review</b></li>
</ol>
<p class="nb-nomodel"><b>The model</b><span>Not on this path. No tokens spent.</span></p>
</div>
<ul class="nb-attn" aria-label="Needs attention panel">
<li class="nb-attn-crit"><b>2</b>Tasks blocked<span>critical</span></li>
<li class="nb-attn-warn"><b>3</b>Drafts awaiting review<span>warning</span></li>
<li class="nb-attn-info"><b>1</b>Reply received<span>info</span></li>
</ul>
<figcaption id="nb6-cap">The wire shapes and the three levels are from the facets docs; the counts are examples.</figcaption>
</figure>

## Lifecycle: installing it buys a domain

Some servers have to do something elsewhere when they're installed. NimbleBrain's go-to-market app buys domains and provisions email inboxes the moment you add it to a workspace. That's two or three layers away from the vendor, and no MCP message says "you were just installed".

The lifecycle extension doesn't do the buying. It's the hook. A server marks one tool for each event, `ready` and `removing`, and the host calls it with an ordinary tool call. Install it, and the server hears "workspace ready", then goes and buys. Uninstall it, and `removing` arrives while the server is still reachable, so it can release what it bought.

The docs are where this gets good, because they're honest about delivery:

- `ready` arrives **at least once**, and twice on a fresh install, in a racy order: once with the reason `install`, once with `resume` as the connection comes up. A host restart is also `resume`, so a restart never looks like a new customer.
- So the handler has to be idempotent: "make this true", an upsert, never an insert.
- `removing` is best effort. If the host was down at uninstall time, it never comes, so a server that leaks a domain without it is relying on a call nothing guarantees. Pair it with a sweep of your own.
- The handler should only record intent and return. A separate process holding the vendor credential does the actual buying, so a bug or a prompt-injected tool call can't reach that credential.
- The host hides the marked tools from everyone else, the model included. Another host might not, so the docs say to write each handler as if any caller could reach it.

<figure class="fd-fig nb nb-life" aria-labelledby="nb7-cap" data-desc="The lifecycle extension as three moments that light in turn. Install: the host calls the tool marked ready with the reason install, and the server records that this workspace wants a domain and inboxes; a separate process holding the vendor credential buys them. Restart: the host calls ready again with the reason resume, and the same upsert changes nothing. Uninstall: the host calls the tool marked removing while the server is still reachable, and the server records the release; because removing is best effort, a sweep of its own catches the uninstalls it never heard about. The model never sees these tools: the host withholds them from every other caller.">
<p class="ea-now nb-say3" aria-hidden="true"><span>Install</span><span>Restart</span><span>Uninstall</span></p>
<ol class="nb-moments nb-cyc3">
<li><span class="nb-mo-k">Install</span><code>ready · reason: "install"</code><span class="nb-mo-do">Record "wants a domain and inboxes". A separate process buys them.</span></li>
<li><span class="nb-mo-k">Restart</span><code>ready · reason: "resume"</code><span class="nb-mo-do">The same upsert. Nothing new to buy.</span></li>
<li><span class="nb-mo-k">Uninstall</span><code>removing</code><span class="nb-mo-do">Record the release. Best effort, so a sweep catches the rest.</span></li>
</ol>
<p class="mp-strip"><b>Host only</b><span>The model never sees these tools. The host withholds them from every other caller.</span></p>
<figcaption id="nb7-cap">His domain-buying app, run through the delivery rules in NimbleBrain's lifecycle docs.</figcaption>
</figure>

## Ten apps should look like one

He showed two apps by two developers. One picked purple, the other green. Side by side, they looked like a hodgepodge.

The MCP Apps spec already lets a host send style variables to the iframe. NimbleBrain sends those, adds the tokens the spec has no key for, like an accent colour, and its MIT-licensed app SDK, Synapse, applies them. Flip the shell to dark mode and every app goes dark. "How many times has somebody tried to render an iframe inside of a dark mode thing and got the light mode?" The same trick gives white-label shells: a customer's colours and fonts are a style kit, not an app reload.

Toasts got the same treatment. The spec's notification message mostly ends up as a log line, so the bridge has a `notify` method with a level and a title, and the shell renders it like its own toasts. In the docs, a success stays five seconds, info six, and warnings and errors until dismissed.

Basic, he admitted. But it's what makes ten or twelve servers feel like one app instead of something stitched together.

<figure class="fd-fig nb nb-tokens" aria-labelledby="nb8-cap" data-desc="Two apps built by two developers, a to-do app and an outbound app, in three states that take turns. First, two looks: one developer used purple, the other green. Second, the host sends its theme tokens and both apps take the shell's cobalt accent. Third, the shell switches to dark mode and both apps go dark together. Under them, a toast sent with the bridge's notify method, level success, title Report exported, description Saved to Files, rendered like the shell's own toasts.">
<p class="ea-now nb-say3" aria-hidden="true"><span>Two developers, two looks</span><span>Host tokens: one look</span><span>Dark mode: both flip</span></p>
<div class="nb-apps">
<div class="nb-app nb-app-a"><p class="nb-app-h">To-dos</p><p class="nb-app-row"><i></i>Review outbound drafts</p><p class="nb-app-row"><i></i>Renew the domain</p><p class="nb-app-btn">Add task</p></div>
<div class="nb-app nb-app-b"><p class="nb-app-h">Outbound</p><p class="nb-app-row"><i></i>3 drafts to review</p><p class="nb-app-row"><i></i>Inbox warming</p><p class="nb-app-btn">Send batch</p></div>
</div>
<p class="nb-toast"><span class="nb-toast-lv">success</span><b>Report exported</b><span>Saved to Files.</span></p>
<figcaption id="nb8-cap">Schematic apps. The two-colour problem and the dark-mode flip are from the talk, the toast from the bridge docs.</figcaption>
</figure>

## What it means for my fifty apps

I run about fifty apps on a Mac mini, each behind [its own Tailscale HTTPS port](/notes/tailscale-serve-self-hosted-apps.html), each with its own front end and its own theme. Ten MCP servers live in their own repos next to them, most of them plain HTTP clients of an app's API, so my agents can use the apps too. This site has one as well: [my portfolio behind an MCP server](/notes/portfolio-mcp-server.html).

So every app my agents touch has two front doors, built twice: a UI for me and an MCP server for them. Goldsborough's package is one door. Tools, UI and skills ship together, the shell docks a chat next to every screen, and the buttons and the agent call the same tools.

Yesterday I built a page that lists every app by port and checks which ones answer. That's his catalog without the composition. Facets would slot right in: a count of what's waiting in each app, on one page, with no model in the loop.

Two honest caveats. I haven't run NimbleBrain yet; everything above comes from the talk, the repo and the docs. And the extensions are NimbleBrain's, under its own prefix. Another host ignores them, which the docs make safe by design, but then you lose the feature. Goldsborough said they want to give back where the spec work already overlaps.

One answer from the Q&A is worth keeping. Asked why MCP Apps and not A2UI, he said they adopted MCP Apps when it launched and haven't seen the value of A2UI yet. And this isn't generative UI. You still design and build the app. It feels like a CRUD app, with an agent layer you get out of the box.

<figure class="fd-fig nb nb-mine" aria-labelledby="nb9-cap" data-desc="My setup today against the package Goldsborough describes. Today, each app has two doors: a front end with its own theme and port, and an MCP server in another repo calling the same API, about fifty apps and ten MCP servers in all. As one package per app: a server.json holding tools, UI and skills, one shell with one chat and one look, and the buttons and the agent calling the same tools. I haven't run NimbleBrain yet; this comes from the talk and the docs.">
<div class="mp-two-row">
<div class="mp-two-card">
<p class="mp-two-who">My setup today</p>
<p class="mp-two-name">Two doors per app</p>
<div class="nb-doors" aria-hidden="true"><span class="nb-mb">Front end</span><span class="nb-mb nb-mb-mcp">MCP server</span><span class="nb-mb nb-mb-api">The app's API</span></div>
<p class="mp-two-line">A front end per app, its own theme and port</p>
<p class="mp-two-line">An MCP server in another repo, same API</p>
<p class="mp-two-line">About fifty apps, ten MCP servers</p>
</div>
<p class="mp-two-vs">vs</p>
<div class="mp-two-card mp-two-peter">
<p class="mp-two-who">One package per app</p>
<p class="mp-two-name">One door</p>
<div class="nb-pkg" aria-hidden="true"><p class="nb-pkg-k">server.json</p><p class="nb-chips"><span>Tools</span><span>UI</span><span>Skills</span></p></div>
<p class="mp-two-line">Tools, UI and skills ship together</p>
<p class="mp-two-line">The buttons and the agent call the same tools</p>
<p class="mp-two-line">One shell, one chat, one look</p>
</div>
</div>
<p class="mp-strip"><b>Not yet tried</b><span>I haven't run NimbleBrain. This is the talk, the repo and the docs.</span></p>
<figcaption id="nb9-cap">My counts against his package. The right card is what I'd get, not something I've built.</figcaption>
</figure>

## Try it

- Watch the talk: [the AGNTCon + MCPCon livestream from 7:12:34](https://youtu.be/iNV5-YkPHW4?t=25954), about 25 minutes with the Q&A.
- Run the host: [NimbleBrainInc/nimblebrain](https://github.com/NimbleBrainInc/nimblebrain), Apache 2.0. `docker compose up` with an Anthropic API key and a one-line `instance.json`.
- Build an app for it, or for any MCP Apps host: [Synapse](https://github.com/NimbleBrainInc/synapse), MIT.
- Read the extensions: [placements](https://docs.nimblebrain.ai/host/placements/), [facets](https://docs.nimblebrain.ai/extensions/facets/), [lifecycle](https://docs.nimblebrain.ai/extensions/lifecycle/) and [the app bridge](https://docs.nimblebrain.ai/apps/bridge/).
- The spec underneath: [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview).

If you've shipped an MCP App outside a chat window, I want to see the host. And if you try facets before I do, tell me what you counted.
