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:toolPlop 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/tasksBuild
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"}
EOFYou 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.