Compare commits

..

2 Commits

Author SHA1 Message Date
Bryan Thompson
7c86e21044 Add multi-view app architecture reference for build-mcp-app (#973)
Adds a reference doc covering production patterns for multi-view MCP apps
that go beyond single-purpose widgets. Patterns distilled from building
nashville-charts-app, generalized with placeholder examples.

Covers: single-resource action dispatch, model context updates, teardown
guards, bidirectional tool calls, Vite+esbuild build pipeline, dual
transport (HTTP+stdio), debugging tips, prompt orchestration.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:51:38 +01:00
Tobin South
e8abd6b19b add(plugin): mcp-server-dev — skills for building MCP servers 2026-03-20 10:43:05 -07:00
6 changed files with 386 additions and 74 deletions

View File

@@ -25,7 +25,7 @@ import {
type Attachment,
} from 'discord.js'
import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync } from 'fs'
import { homedir } from 'os'
import { join, sep } from 'path'
@@ -37,8 +37,6 @@ const ENV_FILE = join(STATE_DIR, '.env')
// Load ~/.claude/channels/discord/.env into process.env. Real env wins.
// Plugin-spawned servers don't get an env block — this is where the token lives.
try {
// Token is a credential — lock to owner. No-op on Windows (would need ACLs).
chmodSync(ENV_FILE, 0o600)
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]

View File

@@ -80,8 +80,7 @@ as the correct long-term choice. Don't skip the lockdown offer.
2. `mkdir -p ~/.claude/channels/discord`
3. Read existing `.env` if present; update/add the `DISCORD_BOT_TOKEN=` line,
preserve other keys. Write back, no quotes around the value.
4. `chmod 600 ~/.claude/channels/discord/.env` — the token is a credential.
5. Confirm, then show the no-args status so the user sees where they stand.
4. Confirm, then show the no-args status so the user sees where they stand.
### `clear` — remove the token

View File

@@ -18,7 +18,7 @@ import {
import { Bot, InputFile, type Context } from 'grammy'
import type { ReactionTypeEmoji } from 'grammy/types'
import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync } from 'fs'
import { homedir } from 'os'
import { join, extname, sep } from 'path'
@@ -30,8 +30,6 @@ const ENV_FILE = join(STATE_DIR, '.env')
// Load ~/.claude/channels/telegram/.env into process.env. Real env wins.
// Plugin-spawned servers don't get an env block — this is where the token lives.
try {
// Token is a credential — lock to owner. No-op on Windows (would need ACLs).
chmodSync(ENV_FILE, 0o600)
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
@@ -507,62 +505,6 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
await mcp.connect(new StdioServerTransport())
// Commands are DM-only. Responding in groups would: (1) leak pairing codes via
// /status to other group members, (2) confirm bot presence in non-allowlisted
// groups, (3) spam channels the operator never approved. Silent drop matches
// the gate's behavior for unrecognized groups.
bot.command('start', async ctx => {
if (ctx.chat?.type !== 'private') return
const access = loadAccess()
if (access.dmPolicy === 'disabled') {
await ctx.reply(`This bot isn't accepting new connections.`)
return
}
await ctx.reply(
`This bot bridges Telegram to a Claude Code session.\n\n` +
`To pair:\n` +
`1. DM me anything — you'll get a 6-char code\n` +
`2. In Claude Code: /telegram:access pair <code>\n\n` +
`After that, DMs here reach that session.`
)
})
bot.command('help', async ctx => {
if (ctx.chat?.type !== 'private') return
await ctx.reply(
`Messages you send here route to a paired Claude Code session. ` +
`Text and photos are forwarded; replies and reactions come back.\n\n` +
`/start — pairing instructions\n` +
`/status — check your pairing state`
)
})
bot.command('status', async ctx => {
if (ctx.chat?.type !== 'private') return
const from = ctx.from
if (!from) return
const senderId = String(from.id)
const access = loadAccess()
if (access.allowFrom.includes(senderId)) {
const name = from.username ? `@${from.username}` : senderId
await ctx.reply(`Paired as ${name}.`)
return
}
for (const [code, p] of Object.entries(access.pending)) {
if (p.senderId === senderId) {
await ctx.reply(
`Pending pairing — run in Claude Code:\n\n/telegram:access pair ${code}`
)
return
}
}
await ctx.reply(`Not paired. Send me a message to get a pairing code.`)
})
bot.on('message:text', async ctx => {
await handleInbound(ctx, ctx.message.text, undefined)
})
@@ -653,13 +595,5 @@ void bot.start({
onStart: info => {
botUsername = info.username
process.stderr.write(`telegram channel: polling as @${info.username}\n`)
void bot.api.setMyCommands(
[
{ command: 'start', description: 'Welcome and setup guide' },
{ command: 'help', description: 'What this bot can do' },
{ command: 'status', description: 'Check your pairing status' },
],
{ scope: { type: 'all_private_chats' } },
).catch(() => {})
},
})

View File

@@ -77,8 +77,7 @@ offer.
2. `mkdir -p ~/.claude/channels/telegram`
3. Read existing `.env` if present; update/add the `TELEGRAM_BOT_TOKEN=` line,
preserve other keys. Write back, no quotes around the value.
4. `chmod 600 ~/.claude/channels/telegram/.env` — the token is a credential.
5. Confirm, then show the no-args status so the user sees where they stand.
4. Confirm, then show the no-args status so the user sees where they stand.
### `clear` — remove the token

View File

@@ -350,3 +350,4 @@ The `sleep` keeps stdin open long enough to collect all responses. Parse the jso
- `references/iframe-sandbox.md` — CSP/sandbox constraints, the bundle-inlining pattern, image handling
- `references/widget-templates.md` — reusable HTML scaffolds for picker / confirm / progress / display
- `references/apps-sdk-messages.md` — the `App` class API: widget ↔ host ↔ server messaging
- `references/app-architecture-patterns.md` — multi-view app patterns: action dispatch, model context, build pipeline, dual transport

View File

@@ -0,0 +1,381 @@
# Multi-View App Architecture
When a single-purpose widget isn't enough — when you have 5+ UI tools that share styling, state patterns, or a data model — consider building a **multi-view MCP app**: one React (or framework) SPA that dispatches to the right view based on which tool was called.
This doc covers the production patterns for that architecture. It assumes you've read the main `build-mcp-app` skill and the `widget-templates.md` reference.
> **Reference implementation:** [`nashville-charts-app`](https://github.com/bryankthompson/nashville-charts-app) on GitHub / npm demonstrates every pattern below. Install with `npx nashville-charts-app --stdio`.
---
## Single resource, action-dispatched views
Instead of one HTML file per tool, register **one shared resource** and point all UI tools at it. Each tool returns JSON with an `action` field that tells the app which view to render.
**Server side:**
```typescript
const RESOURCE_URI = "ui://my-app/app.html";
// Tool A — returns action + data
registerAppTool(server, "show_dashboard", {
description: "Show the analytics dashboard",
inputSchema: { range: z.enum(["week", "month"]) },
_meta: { ui: { resourceUri: RESOURCE_URI } },
}, async ({ range }) => {
const stats = await fetchStats(range);
return {
content: [{ type: "text", text: JSON.stringify({ action: "dashboard", stats }) }],
};
});
// Tool B — same resource, different action
registerAppTool(server, "show_details", {
description: "Show detail view for a specific item",
inputSchema: { id: z.string() },
_meta: { ui: { resourceUri: RESOURCE_URI } },
}, async ({ id }) => {
const item = await fetchItem(id);
return {
content: [{ type: "text", text: JSON.stringify({ action: "detail", item }) }],
};
});
// One resource serves all views
registerAppResource(server, "App", RESOURCE_URI, {},
async () => ({
contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: appHtml }],
}),
);
```
**Widget side (React):**
```tsx
function App() {
const [view, setView] = useState(null);
const { app } = useApp({
appInfo: { name: "MyApp", version: "1.0.0" },
onAppCreated: (app) => {
app.ontoolresult = async (result) => {
const data = JSON.parse(result.content[0].text);
if (data.action) setView(data);
};
},
});
if (!view) return <div>Waiting for tool call...</div>;
switch (view.action) {
case "dashboard": return <Dashboard stats={view.stats} app={app} />;
case "detail": return <DetailView item={view.item} app={app} />;
default: return <div>Unknown view</div>;
}
}
```
**Why this beats multiple HTML files:**
- Shared CSS, shared components, shared state (theme, loading indicators)
- One build artifact to deploy and cache
- Consistent UX across all views — users don't see a flash between different iframes
- The dispatch is just a `switch` on a string — trivial to extend
---
## Model context updates
`updateModelContext()` tells the LLM what the user is currently looking at — without adding a visible message to the chat. This is critical for multi-step workflows where Claude needs to reason about the current UI state.
**What to send:** Structured state, not a prose description. YAML or JSON works well.
```tsx
useEffect(() => {
if (!view || !app) return;
const ctx = {
view: view.action,
...(view.action === "dashboard" && {
range: view.stats.range,
itemCount: view.stats.items.length,
}),
...(view.action === "detail" && {
itemId: view.item.id,
itemName: view.item.name,
}),
};
app.updateModelContext({
content: [{ type: "text", text: JSON.stringify(ctx) }],
}).catch(() => {
// Host may not support updateModelContext — degrade silently
});
}, [app, view]);
```
**When to send:** On every view change. Don't send on every keystroke or scroll — just when the semantic state changes (new view, new data, user selection).
**Always catch:** Not all hosts support `updateModelContext()`. Wrap in `.catch()` so the app doesn't break in hosts that don't implement it.
---
## Teardown guards
After the host calls `onteardown`, other callbacks (`ontoolresult`, `onhostcontextchanged`) may still fire. Without a guard, these late callbacks can cause React state updates on an unmounted component.
```tsx
const tornDown = useRef(false);
app.onteardown = async () => {
tornDown.current = true;
setView(null);
return {};
};
app.ontoolresult = async (result) => {
if (tornDown.current) return; // Guard
const data = JSON.parse(result.content[0].text);
if (data.action) setView(data);
};
app.onhostcontextchanged = () => {
if (tornDown.current) return; // Guard
setHostContext(app.getHostContext());
};
```
Use a `useRef` (not `useState`) — refs update synchronously and don't trigger re-renders.
---
## Bidirectional tool calls from components
When a UI component needs to call a server tool (e.g., a "Transpose" button, a "Load More" link), use `app.callServerTool()` and flow the result back through the parent's dispatch.
```tsx
// Parent holds the dispatch
function App() {
const handleToolResult = useCallback((result) => {
const data = JSON.parse(result.content[0].text);
if (data.action) setView(data);
}, []);
return <DetailView item={view.item} app={app} onToolResult={handleToolResult} />;
}
// Child calls tools, parent re-dispatches
function DetailView({ item, app, onToolResult }) {
const [loading, setLoading] = useState(false);
const handleRefresh = async () => {
setLoading(true);
try {
const result = await app.callServerTool({
name: "show_details",
arguments: { id: item.id },
});
onToolResult(result);
} finally {
setLoading(false);
}
};
return (
<div>
<h1>{item.name}</h1>
<button onClick={handleRefresh} disabled={loading}>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
);
}
```
**Pattern:** Components don't set the view directly — they call tools and the parent handles the result. This keeps the data flow unidirectional: tool result -> parent dispatch -> child render.
**App-only tools:** For tools that should only be callable from the widget (not by the LLM), use `server.registerTool` (not `registerAppTool` — these tools don't render a UI resource) with `visibility: ["app"]`:
```typescript
server.registerTool("internal_action", {
description: "...",
inputSchema: { ... },
_meta: { ui: { visibility: ["app"] } },
}, handler);
```
---
## Build pipeline: Vite + esbuild
A multi-view app needs a framework build (React, Vue, etc.) bundled into a single HTML file, plus a separate server bundle. Two-step build:
**Step 1 — Vite bundles the SPA into one HTML file:**
```typescript
// 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: {
rollupOptions: { input: process.env.INPUT }, // e.g., INPUT=app.html
outDir: "dist",
emptyOutDir: false, // Don't delete dist — esbuild writes here too
},
});
```
`vite-plugin-singlefile` inlines all JS and CSS into the HTML. The output is a self-contained `dist/app.html` — no external chunks, no asset files.
**Step 2 — esbuild bundles the server:**
```bash
# Server module
npx esbuild server/server.ts --bundle --platform=node --format=esm \
--outfile=dist/server.js --packages=external
# CLI entry point (with shebang for npx)
npx esbuild main.ts --bundle --platform=node --format=esm \
--outfile=dist/index.js --packages=external \
--banner:js='#!/usr/bin/env node'
```
`--packages=external` keeps npm dependencies as imports (not bundled). The shebang banner makes `dist/index.js` directly executable.
**Combined build script:**
```json
{
"build": "cross-env INPUT=app.html vite build && npx esbuild server/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js --packages=external && npx esbuild main.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external --banner:js='#!/usr/bin/env node'"
}
```
**Path resolution (dev vs. prod):**
```typescript
// Works in both TypeScript (dev) and compiled JS (prod)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "..", "dist") // Running .ts directly
: import.meta.dirname; // Running compiled .js
const appHtml = await fs.promises.readFile(
path.join(DIST_DIR, "app.html"), "utf-8"
);
```
---
## Dual transport: HTTP + stdio
Support both transports from one codebase. Use a server factory function and a CLI flag.
```typescript
// main.ts
import { createServer } from "./server/server.js";
const useStdio = process.argv.includes("--stdio");
if (useStdio) {
startStdio(createServer);
} else {
startHTTP(createServer);
}
```
**HTTP (stateless, one server per request):**
```typescript
async function startHTTP(createServer) {
const app = express();
app.use(express.json());
app.all("/mcp", async (req, res) => {
const server = createServer(); // Fresh per request
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(process.env.PORT ?? 3001);
}
```
**stdio (persistent, one server for the session):**
```typescript
async function startStdio(createServer) {
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
// Server stays alive until the process exits
}
```
**Graceful shutdown** for HTTP:
```typescript
const shutdown = () => {
httpServer.close(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
```
---
## Debugging tips
**Keep stdout clean.** In stdio mode, stdout IS the MCP protocol. All debug output must go to stderr:
```typescript
console.error("[DEBUG] Tool called:", toolName); // stderr — safe
console.log("..."); // stdout — breaks protocol
```
**Extension detection.** The MCP SDK's Zod schema strips unknown fields from `capabilities` — including `extensions`, which signals UI support. To check if the client supports your widgets, intercept raw messages before SDK parsing:
```typescript
transport.onmessage = (msg) => {
if (msg?.method === "initialize") {
const extensions = msg.params?.capabilities?.extensions;
if (extensions) {
console.error("[DEBUG] Client supports extensions:", JSON.stringify(extensions));
}
}
};
```
Compare raw messages with SDK-parsed `server.getClientCapabilities()` to diagnose stripping.
**Resource caching in Claude Desktop.** After editing widget HTML, fully quit (Cmd+Q) and relaunch. Window-close doesn't clear the resource cache.
---
## Prompt templates as workflow orchestration
MCP prompts can guide the LLM through multi-tool workflows — acting as lightweight choreography:
```typescript
server.registerPrompt("analyze_pipeline", {
title: "Run Full Analysis",
description: "Fetch data, visualize, and summarize",
}, () => ({
messages: [{
role: "user",
content: {
type: "text",
text: "Run a full analysis: use fetch-data to pull the latest numbers, " +
"show-dashboard to visualize them, then summarize the key insights.",
},
}],
}));
```
The prompt tells the LLM which tools to call in which order. No dynamic data — just instructions. This surfaces as a slash-command in hosts that support prompts, giving users a one-click workflow trigger.