MCP tools & widgets
Chapter 4's tool was code you wrote. Real products also need capabilities that live behind
Model Context Protocol servers — a docs-search server, an ops server, a vendor's tool
endpoint. In this chapter you connect the chat to a remote MCP server with the
@netscript/ai/mcp client stack, call one of its tools from the same authorize-gated turn,
and render the ui:// widget an MCP tool result can carry — sandboxed and themed, never
as raw HTML in your island tree.
- 1 · Scaffold
- 2 · Durable chat route
- 3 · Chat UI
- 4 · Server-side tool call
- 5 · MCP tools & widgets
- 6 · Live streaming
The pieces — all shipped, one boundary
Three seams do the work, and every one of them is published and installable today:
| Name | Type | Description |
|---|---|---|
@netscript/ai/mcp |
client stack — jsr:@netscript/ai |
createMcpTransportPool declares remote servers as data (stdio or reconnectable Streamable-HTTP, auth injected); callTool routes a call to its server; extractMcpUiResources pulls the data-only ui:// records out of a result. |
createMcpSandboxHandler |
route — @netscript/fresh/ai/sandbox |
Serves a registered ui:// resource themed with the active token set and behind a per-response CSP. The serving half of the render path. |
McpUiWidget |
island — fresh-ui copy registry |
The copied island (installed by netscript ui:add ai in chapter 3) that renders a ui:// resource in a sanitized, no-referrer sandboxed frame through the sandbox route. |
What you will build
A searchRemote tool whose handler routes through an MCP transport pool instead of your own
code: the model calls it like any chapter-4 tool, the pool executes it on the remote server,
and the result — including any ui:// widget the server returns — lands in the durable
transcript and renders in the chat, themed and walled off behind a per-response CSP.
Before you begin
You need the working chat from chapters 1–4, plus the engine package, which owns the MCP client stack:
deno add jsr:@netscript/ai
You also need an MCP server to talk to — any Streamable-HTTP MCP endpoint you have access to works (a docs-search server is the running example below). Put its URL and token in the app environment next to your model key, never in code.
Step 1 — Declare the server pool
createMcpTransportPool turns server declarations into a connected tool surface. The configs
are serializable data — kind, id, URL, auth — so they can live in configuration. Auth is
injected here at the composition root, never baked into a transport:
// apps/dashboard/lib/mcp/pool.ts
import { createMcpTransportPool } from '@netscript/ai/mcp';
export const mcpPool = createMcpTransportPool({
servers: [{
kind: 'streamable-http',
serverId: 'search',
url: Deno.env.get('MCP_SEARCH_URL')!,
auth: { mode: 'api-token', token: Deno.env.get('MCP_TOKEN')!, scheme: 'Bearer' },
}],
});
// connect() opens every pooled transport and returns the discovered tools,
// name-prefixed by server id ("search:…") so two servers' tools never collide.
export const mcpTools = await mcpPool.connect({});
The Streamable-HTTP transport is built for long-lived sessions: it reconnects with backoff,
and reconnecting is a first-class McpConnectionState, not an error. Add a second server to
the servers array and the pool keeps their tools namespaced — many servers, one surface.
Step 2 — Bridge a pooled tool into the chat turn
Your route's model call runs on @tanstack/ai (chapter 2), so give the model a chapter-4-style
tool whose handler routes through the pool. callTool sends the prefixed tool name to its
server and hands back the result with any ui:// resources already extracted:
// apps/dashboard/lib/tools/search-remote.ts
import { toolDefinition } from '@tanstack/ai';
import { mcpPool } from '../mcp/pool.ts';
export const searchRemote = toolDefinition({
name: 'searchRemote',
description: 'Search the product docs. Use for any product or how-to question.',
inputSchema: {
type: 'object',
properties: { query: { type: 'string', description: 'The question to look up.' } },
required: ['query'],
},
}).server(async ({ query }: { query: string }) => {
// Route the call to the remote server through the pool.
const result = await mcpPool.callTool('search:search_docs', { query }, {});
// Plain structured output, like chapter 4 — plus the extracted ui:// records.
return { result, uiResources: result.uiResources ?? [] };
});
Add searchRemote to the same tools array as chapter 4's searchDocs. Nothing else in the
route changes: the tool runs server-side, inside the authorize-gated turn, and the whole
exchange — arguments, result, and the ui:// references — is persisted as a transport tool
part in the durable session.
Step 3 — Render the ui:// widget, sandboxed
An MCP tool result can embed ui:// resources — UI authored by the server, not by you.
They reach the page only through two guardrails, both already in your app:
- The sandbox route. Mount
createMcpSandboxHandleronce; it serves a registered resource selected by?uri=, injects the active theme's--ns-*custom properties, stampsdata-theme, and applies a per-response CSP derived from the resource URI.?theme=picks a token set; an unknown theme falls back todefaultThemeName("default").
// apps/dashboard/routes/mcp/sandbox.ts
import { createMcpSandboxHandler } from '@netscript/fresh/ai/sandbox';
import { resourceRegistry } from '../../lib/mcp/registry.ts';
export const handler = {
GET: createMcpSandboxHandler({
resolveResource: (uri, { signal }) => resourceRegistry.lookup(uri, { signal }),
themes: {
default: { '--ns-color-surface': '#ffffff' },
dark: { '--ns-color-surface': '#111111' },
},
}),
};
- The widget island. The
aicollection you copied in chapter 3 includes theMcpUiWidgetisland: hand it aui://resource from the tool part's output and it renders the widget in a sanitized,no-referrersandboxed frame served through the route above. In the chapter-4 render switch, a tool part whose output carriesuiResourcesrenders those throughMcpUiWidgetalongside (or instead of) the plain tool-call card.
Verify your progress
With aspire start running and your MCP server reachable:
- Ask a question that triggers the remote tool. The reply carries a tool-call card naming
searchRemote— its result came over MCP, not from your code. - If the server returned a
ui://resource, the widget renders inside the chat, themed like the page around it. - Probe the sandbox route directly and check the isolation headers:
curl -i 'http://localhost:8010/mcp/sandbox?uri=ui%3A%2F%2Fwidget%2Fdemo&theme=dark'
You should see the resolved resource with data-theme="dark" and the dark --ns-* tokens,
under a content-security-policy response header derived from the resource URI.
- Reload. The tool card — and the widget reference in its output — replays from the durable session, exactly like every other part of the transcript.
- Type-check the app:
deno task --cwd apps/dashboard check
- [ ]
deno add jsr:@netscript/airesolved and the poolconnect()s to your server. - [ ] Asking a relevant question triggers the
searchRemotetool over MCP. - [ ] A
ui://result renders throughMcpUiWidget, themed and framed. - [ ] The sandbox route answers with theme tokens and a per-response CSP.
- [ ] A reload replays the tool card and its widget reference.
- [ ]
deno task --cwd apps/dashboard checkis clean.
What you built
A chat whose tools no longer stop at your own code: a declared MCP server pool, a bridged tool
the model calls like any other, and server-authored ui:// widgets rendered through the
sandbox route and the copied McpUiWidget island — durable in the transcript, isolated on the
page. Next you make the whole transcript feel live — folding the model's chunks into the UI as
they arrive instead of after each turn settles.