Skip to content

jasonwells/nanoapps

Repository files navigation

 ███╗   ██╗ █████╗ ███╗   ██╗ ██████╗  █████╗ ██████╗ ██████╗ ███████╗
 ████╗  ██║██╔══██╗████╗  ██║██╔═══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝
 ██╔██╗ ██║███████║██╔██╗ ██║██║   ██║███████║██████╔╝██████╔╝███████╗
 ██║╚██╗██║██╔══██║██║╚██╗██║██║   ██║██╔══██║██╔═══╝ ██╔═══╝ ╚════██║
 ██║ ╚████║██║  ██║██║ ╚████║╚██████╔╝██║  ██║██║     ██║     ███████║
 ╚═╝  ╚═══╝╚═╝  ╚═╝╚═╝  ╚═══╝ ╚═════╝ ╚═╝  ╚═╝╚═╝     ╚═╝     ╚══════╝

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 zod

zod 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-apps

If you're using the Hono adapter (nanoapps/hono), install Hono as well:

bun add hono

Define 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_task

Multi-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.js

By 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 stop

Serve

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 port

This 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 mode

The 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 nanoapps

Testing

bun test

See docs/testing.md for test structure and writing tests, and docs/curl-testing.md for manual testing with curl.

About

MCP Apps made simple with Bun — self-contained nanoapps with single-file HTML UIs

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors