Notes · Article
Every screen in this SaaS app is an MCP App
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. 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 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.
- Workspace
- Conversations
- Files
- Tasks
- Apps
- To-dos
- People
- Memory
Host views, then app placements
To-dos
- Review outbound drafts
- Renew the sending domain
- Sync the CRM
+ Add task
ui://todos in an iframe. Buttons are tool calls
Chat
What's next on my list?
Review outbound drafts.
The agent loop, skills over MCP
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.
- .exeThe desktop
- .warJava web apps
- .debLinux
- MCPAgents, and now whole apps
1Tools
What the server lets the model do
2Apps
An interface in an iframe; every button is a tool call
3Skills
The know-how, loaded over MCP
The packageA server.json and a couple of extensions hold all three.
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.
In a chat host
Their sandbox
Your package, your server
Their walls: where it renders, how big, what it reaches
One host to one server, no server to server
or
In your own host
Same plumbing, again
You own the runtime
Identity, files, conversations, routing: rebuilt per product
Business users still get a chat box
NimbleBrainBuild the host once, as open source, and let every server use it.
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.
Front doors
The web shellYour MCP clientCoding agents, harnesses
Host primitives
FilesConversationsTool routingIdentityWorkspaces
The MCP spec
ToolsResourcesAppsSkills
Servers
Theirs: search, memory, CRM, outboundYoursAny public oneA broker to a 20-year-old Postgres
- FinanceSage · Stripe
- EngineeringGitHub · Linear
- MineGitHub
RunsOn-prem, a private cloud or their managed service, with any OpenAI-compatible model.
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.
"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.
/w/ops/app/tasks
- Conversations
- Files
- Apps
- Tasks
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:
{ "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.
- Host → server
resources/list - A resource carries
"ai.nimblebrain/facets": { "level": "warning" } - Host → server
resources/read → { "count": 3 } - Overview3 Drafts awaiting review
The modelNot on this path. No tokens spent.
- 2Tasks blockedcritical
- 3Drafts awaiting reviewwarning
- 1Reply receivedinfo
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:
readyarrives at least once, and twice on a fresh install, in a racy order: once with the reasoninstall, once withresumeas the connection comes up. A host restart is alsoresume, so a restart never looks like a new customer.- So the handler has to be idempotent: "make this true", an upsert, never an insert.
removingis 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.
- Install
ready · reason: "install"Record "wants a domain and inboxes". A separate process buys them. - Restart
ready · reason: "resume"The same upsert. Nothing new to buy. - Uninstall
removingRecord the release. Best effort, so a sweep catches the rest.
Host onlyThe model never sees these tools. The host withholds them from every other caller.
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.
To-dos
Review outbound drafts
Renew the domain
Add task
Outbound
3 drafts to review
Inbox warming
Send batch
successReport exportedSaved to Files.
What it means for my fifty apps
I run about fifty apps on a Mac mini, each behind its own Tailscale HTTPS port, 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.
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.
My setup today
Two doors per app
A front end per app, its own theme and port
An MCP server in another repo, same API
About fifty apps, ten MCP servers
vs
One package per app
One door
Tools, UI and skills ship together
The buttons and the agent call the same tools
One shell, one chat, one look
Not yet triedI haven't run NimbleBrain. This is the talk, the repo and the docs.
Try it
- Watch the talk: the AGNTCon + MCPCon livestream from 7:12:34, about 25 minutes with the Q&A.
- Run the host: NimbleBrainInc/nimblebrain, Apache 2.0.
docker compose upwith an Anthropic API key and a one-lineinstance.json. - Build an app for it, or for any MCP Apps host: Synapse, MIT.
- Read the extensions: placements, facets, lifecycle and the app bridge.
- The spec underneath: MCP Apps.
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.
Plain text: every-screen-is-an-mcp-app.md · Atom feed · All notes