Compare commits

..

5 Commits

Author SHA1 Message Date
Kenneth Lien
3d8042f259 Silently return when bot.stop() aborts the setup phase
If bot.stop() is called while bot.start() is still in setup (deleteWebhook/
getMe), grammy rejects with 'Aborted delay'. Expected, not an error.
2026-03-20 11:07:05 -07:00
Kenneth Lien
1daff5f224 telegram: retry on 409 Conflict instead of crashing
During /mcp reload or when a zombie from a previous session still holds
the polling slot, the new process gets 409 Conflict on its first
getUpdates and dies immediately. Retry with backoff until the slot
frees — typically within a second or two.

Also handles the two-sessions case: the second Claude Code instance
keeps retrying (with a clear message about what's happening) and takes
over when the first one exits.

Fixes #804 #794, partial #788 (issue 4)
2026-03-20 10:55:27 -07:00
Tobin South
90accf6fd2 add(plugin): mcp-server-dev — skills for building MCP servers (#731) 2026-03-20 17:51:32 +00:00
Kenneth Lien
562a27feec Merge pull request #811 from anthropics/kenneth/chmod-env-files
Lock telegram/discord .env files to owner (chmod 600)
2026-03-20 10:48:05 -07:00
Kenneth Lien
8140fbad22 Lock telegram/discord .env files to owner (chmod 600)
The bot token is a credential. Tighten perms on load so hand-written
or pre-existing .env files get locked down, and update the configure
skill to chmod after writing. No-op on Windows.
2026-03-20 10:37:13 -07:00
6 changed files with 43 additions and 393 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 } from 'fs'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { homedir } from 'os'
import { join, sep } from 'path'
@@ -37,6 +37,8 @@ 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,7 +80,8 @@ 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. Confirm, then show the no-args status so the user sees where they stand.
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.
### `clear` — remove the token

View File

@@ -15,10 +15,10 @@ import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { Bot, InputFile, type Context } from 'grammy'
import { Bot, GrammyError, InputFile, type Context } from 'grammy'
import type { ReactionTypeEmoji } from 'grammy/types'
import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync } from 'fs'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { homedir } from 'os'
import { join, extname, sep } from 'path'
@@ -30,6 +30,8 @@ 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]
@@ -591,9 +593,35 @@ async function handleInbound(
})
}
void bot.start({
onStart: info => {
botUsername = info.username
process.stderr.write(`telegram channel: polling as @${info.username}\n`)
},
})
// 409 Conflict = another getUpdates consumer is still active (zombie from a
// previous session, or a second Claude Code instance). Retry with backoff
// until the slot frees up instead of crashing on the first rejection.
void (async () => {
for (let attempt = 1; ; attempt++) {
try {
await bot.start({
onStart: info => {
botUsername = info.username
process.stderr.write(`telegram channel: polling as @${info.username}\n`)
},
})
return // bot.stop() was called — clean exit from the loop
} catch (err) {
if (err instanceof GrammyError && err.error_code === 409) {
const delay = Math.min(1000 * attempt, 15000)
const detail = attempt === 1
? ' — another instance is polling (zombie session, or a second Claude Code running?)'
: ''
process.stderr.write(
`telegram channel: 409 Conflict${detail}, retrying in ${delay / 1000}s\n`,
)
await new Promise(r => setTimeout(r, delay))
continue
}
// bot.stop() mid-setup rejects with grammy's "Aborted delay" — expected, not an error.
if (err instanceof Error && err.message === 'Aborted delay') return
process.stderr.write(`telegram channel: polling failed: ${err}\n`)
return
}
}
})()

View File

@@ -77,7 +77,8 @@ 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. Confirm, then show the no-args status so the user sees where they stand.
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.
### `clear` — remove the token

View File

@@ -350,4 +350,3 @@ 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

@@ -1,381 +0,0 @@
# 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.