SaaS Starter

MCP server

Expose the boilerplate's REST API to LLM clients (Claude Desktop, Claude Code) over Model Context Protocol.

The @app/mcp package ships a thin Model Context Protocol (MCP) server that wraps the boilerplate's /api/v1/... HTTP API. Drop a Bearer API key into the env, point Claude Desktop or Claude Code at the bundled dist/index.mjs, and the LLM can list/create/update domain entities through tool calls.

What's in the box

packages/mcp/
├── package.json     # @app/mcp, bin: app-mcp
├── tsconfig.json
├── scripts/bundle.ts        # builds a single Node-runnable .mjs with shebang
├── README.md
└── src/
    ├── index.ts             # entrypoint — reads APP_API_URL / APP_API_TOKEN
    ├── server.ts            # MCP Server wiring (tools + resources)
    ├── client.ts            # generic HTTP client (Bearer auth, JSON in/out)
    ├── tools/
    │   ├── index.ts         # TOOLS array — generator marker lives here
    │   ├── list-notifications.ts        # example tool
    │   └── mark-notification-read.ts    # example tool
    └── resources/
        ├── index.ts
        └── inbox.ts          # example resource (pendi://inbox)

The two example tools call the existing /api/v1/notifications endpoints so they work end-to-end on a freshly cloned boilerplate. Replace or extend them to expose your own domain.

Adding a new tool

bun gen mcp:tool

Plop asks for:

  • name (kebab-case, e.g. list-tasks)
  • description (one line shown to the LLM)
  • HTTP verb (GET / POST / PATCH / DELETE)
  • API path (use {id} for a path param)

It drops a templated src/tools/<name>.ts and wires it into src/tools/index.ts between the <generator:tools> markers. Both branches (with/without {id} param) compile and lint cleanly out of the box. Edit the handler body to shape the result string the LLM sees.

You can also pass the answers as flags for non-interactive use:

bun gen mcp:tool list-tasks -- \
  --description="List tasks for the active org" \
  --verb=GET --path=/api/v1/tasks

Build

bun --cwd packages/mcp run build
# → packages/mcp/dist/index.mjs (single-file Node-runnable bundle, ~600KB)

The bundle is chmod +x and starts with #!/usr/bin/env node. No runtime deps beyond a Node 22+ binary.

Auth — minting an API key

API keys are scoped to an organization. The IAM module persists only the sha256 hash; the plaintext is shown ONCE.

There's no UI yet — use the recovery CLI:

DATABASE_URL="<prod-db-url>" \
  bun --cwd apps/server run mint:api-key -- \
    --organization-id=<orgId> \
    --email=<your-email> \
    --name="MCP (laptop)"

Output:

API key minted (id=…, name=MCP (laptop)).
Plaintext secret (shown ONCE — save it now):
fk_live_<43-char base64url>

The default permission set is *:*. Pass --permissions="tasks:read,tasks:write" for least-privilege keys.

Wire it into a client

Claude Code (repo-local)

Drop a .mcp.json at the repo root (gitignored — see .mcp.json.example):

{
  "mcpServers": {
    "app": {
      "command": "node",
      "args": ["./packages/mcp/dist/index.mjs"],
      "env": {
        "APP_API_URL": "https://api.example.com",
        "APP_API_TOKEN": "fk_live_…"
      }
    }
  }
}

Claude Code auto-detects this when you launch from the repo root.

Claude Desktop

Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS — equivalent paths on Windows/Linux):

{
  "mcpServers": {
    "app": {
      "command": "node",
      "args": ["/absolute/path/to/repo/packages/mcp/dist/index.mjs"],
      "env": {
        "APP_API_URL": "https://api.example.com",
        "APP_API_TOKEN": "fk_live_…"
      }
    }
  }
}

Restart Claude Desktop after editing.

Smoke test

APP_API_URL=https://api.example.com APP_API_TOKEN=fk_live_… \
  node packages/mcp/dist/index.mjs <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
EOF

You should see a result listing the registered tools.

Multi-org keys

If your user belongs to multiple organizations and the API key spans more than one (rare — keys are usually pinned to a single org), set APP_ORG_ID so the MCP scopes its requests. The middleware validates the header against the user's memberships and rejects mismatches.

Permissions

The key's permissions cap what every tool can do. Granting *:* is the simplest path; for least-privilege, mint a separate key per integration with only the verbs it needs (tasks:read, tasks:write, comments:write, etc.). The bundled tools surface the API's 403 verbatim, so you can diagnose missing scopes from the LLM transcript.

Revoke

Keys live in api_keys. Revoke by setting revokedAt:

UPDATE api_keys SET "revokedAt" = NOW() WHERE id = '<keyId>';

apiKeyAuth rejects with 401 on the next request.

On this page