nanoapps
v0.1.0
Published
``` ███╗ ██╗ █████╗ ███╗ ██╗ ██████╗ █████╗ ██████╗ ██████╗ ███████╗ ████╗ ██║██╔══██╗████╗ ██║██╔═══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝ ██╔██╗ ██║███████║██╔██╗ ██║██║ ██║███████║██████╔╝██████╔╝███████╗ ██║╚██╗██║██╔══██║██║╚██╗██║██║ ██║
Downloads
224
Readme
███╗ ██╗ █████╗ ███╗ ██╗ ██████╗ █████╗ ██████╗ ██████╗ ███████╗
████╗ ██║██╔══██╗████╗ ██║██╔═══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝
██╔██╗ ██║███████║██╔██╗ ██║██║ ██║███████║██████╔╝██████╔╝███████╗
██║╚██╗██║██╔══██║██║╚██╗██║██║ ██║██╔══██║██╔═══╝ ██╔═══╝ ╚════██║
██║ ╚████║██║ ██║██║ ╚████║╚██████╔╝██║ ██║██║ ██║ ███████║
╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝Build MCP Apps servers with self-contained tools and interactive HTML UIs that render inline in MCP-compatible clients. Define your tools with defineApp(), build them with the CLI, and serve them on a single /mcp endpoint supporting both stateful and stateless connections.
Install
bun add nanoapps zodzod is a required peer dependency (used for inputSchema definitions). If your apps include a UI, you'll also need the MCP Apps client SDK for host communication (theme, messaging, etc.):
bun add @modelcontextprotocol/ext-appsIf you're using the Hono adapter (nanoapps/hono), install Hono as well:
bun add honoDefine an app
Each app is a tool.ts file that default-exports a defineApp() call. There are three app types:
Single tool with UI
The simplest app — one tool backed by one UI.
// src/apps/greet/tool.ts
import { defineApp } from "nanoapps";
import { z } from "zod";
export default defineApp({
name: "greet",
title: "Greeter",
description: "Greets someone by name.",
inputSchema: { name: z.string() },
handler: async (args) => `Hello, ${args.name}!`,
});Add a ui/ folder alongside tool.ts with index.html, app.ts, and styles.css for an interactive UI. UI code uses @modelcontextprotocol/ext-apps to communicate with the MCP host — see src/apps/hello/ui/ for a working example.
Tool-only (no UI)
Omit the ui/ directory — the tool registers without a resource:
// src/apps/guid/tool.ts
import { defineApp } from "nanoapps";
export default defineApp({
name: "guid",
title: "GUID Generator",
description: "Generate a random GUID/UUID v4.",
handler: async () => crypto.randomUUID(),
});Multi-tool
Multiple tools sharing a single UI resource. Pass a tools array instead of description/handler. Each tool name is auto-prefixed with the app name:
// src/apps/kanban/tool.ts
import { defineApp } from "nanoapps";
import { z } from "zod";
export default defineApp({
name: "kanban",
title: "Kanban Board",
tools: [
{
name: "list_tasks",
description: "List all tasks on the board",
handler: async () => JSON.stringify({ board: {} }),
},
{
name: "add_task",
description: "Add a new task",
inputSchema: { title: z.string() },
handler: async (args) => JSON.stringify({ added: args.title }),
},
],
});
// Registers: kanban_list_tasks, kanban_add_taskMulti-tool apps work with or without a UI. See src/apps/kanban/ for a full working example.
Authentication
nanoapps supports passing auth context from your HTTP middleware into tool handlers. Provide a resolveAuth callback when creating the handler, and every tool receives the resolved identity via the context parameter.
Configure auth resolution
import { Hono } from "hono";
import { collectApps } from "nanoapps";
import { mcpHandler } from "nanoapps/hono";
import { verifyJwt } from "./auth";
const app = new Hono();
app.all("/mcp", mcpHandler({
name: "my-server",
version: "1.0.0",
registrations: await collectApps("./dist"),
resolveAuth: async (request) => {
const user = await verifyJwt(request.headers.get("authorization"));
if (!user) return undefined;
return {
token: "",
clientId: user.id,
scopes: user.scopes,
extra: { email: user.email, role: user.role },
};
},
}));The resolveAuth callback receives the raw Request and returns an AuthInfo object (or undefined). It is called once per request — for both stateless and stateful session requests. Only read headers; do not consume the request body.
Access auth in tool handlers
Every handler receives a second context argument with the resolved auth:
import { defineApp } from "nanoapps";
export default defineApp({
name: "profile",
title: "User Profile",
description: "Returns the current user's profile.",
handler: async (args, context) => {
const userId = context.authInfo?.clientId;
const email = context.authInfo?.extra?.email;
if (!userId) return "Not authenticated";
return JSON.stringify({ userId, email });
},
});The context object contains:
| Field | Type | Description |
|-------|------|-------------|
| authInfo | AuthInfo \| undefined | Auth info returned by resolveAuth |
| sessionId | string \| undefined | MCP session ID (stateful connections only) |
When no resolveAuth is configured, context.authInfo is undefined. The context parameter is always provided — handlers that don't need auth can simply ignore it.
Session Handling
By default, stateful sessions are stored in memory with a 30-minute idle TTL. You can replace the built-in store by passing a sessionStore to createMcpHandler (or mcpHandler).
Custom session store
Implement the SessionStore interface — three methods:
import type { SessionStore, SessionData } from "nanoapps";
class RedisSessionStore implements SessionStore {
get(id: string): SessionData | undefined { /* look up session, reset idle timeout */ }
set(id: string, session: SessionData): void { /* store session */ }
delete(id: string): void {
const session = this.sessions.get(id);
if (session) {
this.sessions.delete(id);
session.transport.close(); // required — releases resources
}
}
}Then pass it when creating the handler:
const handler = createMcpHandler({
name: "my-server",
version: "1.0.0",
registrations: await collectApps("./dist"),
sessionStore: new RedisSessionStore(),
});When sessionStore is provided, sessionTtlMs is ignored — your store owns the lifecycle.
Configuring the default store
Without a custom store, use sessionTtlMs to change the idle timeout:
createMcpHandler({
// ...
sessionTtlMs: 10 * 60 * 1000, // 10 minutes
});Or use MemorySessionStore directly for the same default behavior:
import { MemorySessionStore } from "nanoapps";
createMcpHandler({
// ...
sessionStore: new MemorySessionStore(10 * 60 * 1000),
});Build
Build tool.ts files and UIs into self-contained dist directories:
bunx nanoapps build # defaults: src/apps → dist
bunx nanoapps build src/apps dist # explicit paths
bunx nanoapps build --watch # watch mode — rebuild on file changes
bunx nanoapps build --standalone # bundle all deps into tool.jsBy default, built tool.js files keep npm packages as external imports (resolved from your node_modules). Use --standalone to bundle everything into each tool.js so the output has no runtime dependencies.
Or use the programmatic API:
import { buildApps, watchApps } from "nanoapps/build";
// One-shot build
await buildApps({ appsDir: "src/apps", outDir: "dist" });
// Standalone — bundle all deps into tool.js
await buildApps({ appsDir: "src/apps", outDir: "dist", standalone: true });
// Watch mode — returns a handle to stop watching
const handle = await watchApps({ appsDir: "src/apps", outDir: "dist" });
// handle.close() to stopServe
Run without a server file
Serve your built apps directly from the CLI — no server code needed:
bunx nanoapps run # serves from ./dist on port 3000
bunx nanoapps run ./my-apps # custom apps directory
bunx nanoapps run --port 8080 # custom portThis starts a server with /mcp and /health endpoints. The apps directory can also be set via NANOAPPS_DIR or PORT environment variables.
Mount with Hono
import { Hono } from "hono";
import { collectApps } from "nanoapps";
import { mcpHandler } from "nanoapps/hono";
const app = new Hono();
app.all("/mcp", mcpHandler({
name: "my-server",
version: "1.0.0",
registrations: await collectApps("./dist"),
}));
export default { port: 3000, fetch: app.fetch };Mount with any framework
The core createMcpHandler returns a (request: Request) => Promise<Response> function that works with any framework supporting the Web Standard Request/Response API.
import { createMcpHandler, collectApps } from "nanoapps";
const handler = createMcpHandler({
name: "my-server",
version: "1.0.0",
registrations: await collectApps("./dist"),
});
Bun.serve({ port: 3000, fetch: handler });Auto-discover apps with collectApps
Load all apps from a directory (pre-built or source):
import { collectApps } from "nanoapps";
// Load pre-built apps from dist/ (tool.js + app.html co-located)
const registrations = await collectApps("./dist");
// Load source tools from src/apps/ with built HTML from dist/
const registrations = await collectApps({
appsDir: "./src/apps",
distDir: "./dist",
});Contributing
Quick start
mise install # install Bun 1.3.10
bun install # install dependencies
bun run build:apps # build app UIs into single HTML files
bun run dev # start dev server with watch modeThe server starts at http://localhost:3000. Connect any MCP client to http://localhost:3000/mcp.
Scripts
| Command | Description |
|---------|-------------|
| bun run dev | Build watcher + dev server |
| bun run build:apps | Build all apps (tools + UIs) into dist/ |
| bun run start | Production server |
| bun test | Run tests |
| bun run check | Lint with Biome |
| bun run fix | Lint + autofix with Biome |
| bun run inspector | Launch MCP protocol inspector |
Docker
docker build -t nanoapps .
docker run -p 3000:3000 nanoappsTesting
bun testSee docs/testing.md for test structure and writing tests, and docs/curl-testing.md for manual testing with curl.
