MCP Server
Open Saasframe ships an MCP (Model Context Protocol) server that exposes your tenant's tools to an external AI client — Claude Code, the MCP Inspector, or any MCP-compatible client — over HTTP. Point the client at a running Open Saasframe server and it can discover and call your tools, scoped to an API key's permissions.
This is the client-facing MCP entry point. It is distinct from the in-app AI Framework (typed agents, mutation approvals, <AiChat>) documented in AI Framework Overview. The MCP server serves tools; agents are a separate in-app surface (see the "Tools vs agents" note below).
Setup & connecting Claude Code
The MCP server exposes Open Saasframe's tools to an external AI client — Claude Code, MCP Inspector, or any MCP-compatible client — over HTTP using the MCP StreamableHTTP transport. Point Claude Code at a running Open Saasframe dev server and it can call your tenant's tools directly.
Two server modes
The server runs in one of two modes depending on how you intend to use it:
Dev (yarn mcp:dev) | Production (yarn mcp:serve) | |
|---|---|---|
| Alias for | saasframe ai_assistant mcp:dev | saasframe ai_assistant mcp:serve-http |
| Use for | Claude Code / local testing | In-app web chat |
| Port | 3001 (override with MCP_DEV_PORT) | — |
| Endpoint | http://localhost:3001/mcp | — |
| Health | http://localhost:3001/health | — |
| Auth | A single API key, read once at startup | Two-tier: server key + per-user session tokens, checked per tool call |
| Key source | .mcp.json → mcpServers.saasframe.headers.x-api-key (no env fallback) | MCP_SERVER_API_KEY env var + session tokens |
You do not need MCP_SERVER_API_KEY for mcp:dev. The MCP_SERVER_API_KEY not set message on the in-app AI Assistant Settings page is only a status badge for the production/web-chat path — it does not block Claude Code connecting via mcp:dev.
How ACL gates which tools appear
The dev server filters the available tools by the API key's ACL at startup. The key's roles and features decide which tools are visible:
- A key with no roles or features sees only the
context_whoamitool — the one tool with no required features. - Bind the key to a role like
admin(which aggregates every module's<module>.*grants) so all tools appear. - Wildcard grants (
module.*,*) satisfy concrete required features.
Setup steps
-
Create an API key. Via the CLI:
yarn saasframe api_keys add --name claude-code \--tenantId <tenant-uuid> \--organizationId <org-uuid> \--roles adminThis prints a secret like
omk_<prefix>.<secret>once — store it immediately. You can also create or edit keys in the app UI under Settings → API Keys. -
Write
.mcp.json. Place it at your project root, where Claude Code auto-discovers it. The same file serves two purposes: it tells Claude Code how to connect, and the dev server reads its own API key from it.{"mcpServers": {"saasframe": {"type": "http","url": "http://localhost:3001/mcp","headers": { "x-api-key": "omk_your_key_here" }}}} -
Start the dev server.
yarn mcp:devThe server authenticates once at startup using the
x-api-keyfrom.mcp.jsonand filters tools by that key's ACL.tipSome tools — the
executeCode Mode tool and the API-backed module tools — call back into the running Next.js app over HTTP (base URL defaults tohttp://localhost:3000). For those to work, also runyarn devin a separate terminal. Thecontext_whoamiandsearchtools do not need the app running. -
Connect in Claude Code. Run
/mcpto approve and trust the project server. After editing.mcp.jsonor restarting the server, restart Claude Code or reconnect via/mcp. -
Verify connectivity.
curl http://localhost:3001/healthA healthy server returns:
{"status":"ok","mode":"development","tools":N,...}
Zero-features pitfall: if your API key has no roles or features, the server starts fine but exposes only context_whoami — every other tool silently disappears. If Claude Code sees just one tool, your key is missing grants. Bind it to a role such as admin (or a role with the relevant <module>.* wildcard grants) and reconnect.
Stdio server authentication (saasframe ai_assistant mcp:serve)
The saasframe ai_assistant mcp:serve command runs the MCP server over stdio (one process per client, used by some local MCP clients). It is a distinct command from the HTTP servers above — note that the yarn mcp:serve script is aliased to mcp:serve-http, so this stdio command is reached by invoking the saasframe CLI directly.
The stdio server resolves its auth context in priority order:
- API key — pass
--api-key <secret>, or setSAASFRAME_API_KEY(preferred — keeps the secret offargv, which is world-readable viaps//proc). The key's tenant, organization, user, and ACL are loaded from the key. - Manual context — pass
--tenant <id>together with--user <id>(and optionally--org <id>). The named user's ACL is loaded and enforced. - Explicit unauthenticated opt-in —
--allow-unauthenticated-superadmin(see below).
If you supply none of the above — for example --tenant without --user and without an API key — the server refuses to start rather than silently escalating to an unscoped superadmin. You will see:
MCP server refused to start: no authentication provided. Supply a valid apiKeySecret,
a context with a non-empty userId, or explicitly set allowUnauthenticatedSuperadmin: true
for local development.
An empty or whitespace-only API key is treated as missing, so a blank --api-key cannot fall through into an unauthenticated branch. With --tenant, --user is therefore effectively required unless you pass the explicit opt-in below.
--allow-unauthenticated-superadmin (dev/test only)
For local development and testing you can restore the legacy "no user → superadmin" behavior with an explicit, loud opt-in:
saasframe ai_assistant mcp:serve \
--tenant 123e4567-e89b-12d3-a456-426614174000 \
--allow-unauthenticated-superadmin
When enabled, the server runs as superadmin with no per-user ACL (scoped only to whatever --tenant / --org you pin) and logs a loud startup warning:
[MCP Server] WARNING: allowUnauthenticatedSuperadmin is enabled — running with
UNAUTHENTICATED SUPERADMIN access and no per-user ACL. Do not use this outside
local development/testing.
The same switch is available programmatically as the optional McpServerOptions.allowUnauthenticatedSuperadmin flag on runMcpServer(...) / createMcpServer(...). It defaults to off.
--allow-unauthenticated-superadmin bypasses per-user RBAC entirely. Use it only for local development or automated tests. In production, always authenticate with an API key or a --tenant + --user context.
Migration impact
allowUnauthenticatedSuperadmin is an additive, optional field (default off), so the only behavior change is that a previously fail-open misconfiguration now fails closed. If you previously started the stdio mcp:serve with --tenant but no --user (and no API key) and relied on the implicit superadmin context, that invocation now refuses to start. Migrate it by either:
- supplying
--user <id>so a real user's ACL is enforced (recommended), or - adding
--allow-unauthenticated-superadminif you genuinely need the unscoped superadmin context for local dev/test.
What's available & how to use it
After a correct setup with an admin-scoped API key, the MCP server exposes 70 tools total, organized into three groups. The key thing to internalize: MCP serves tools, and any documented API route is reachable through the Code Mode meta-tools — you do not get one MCP tool per route, and you do not connect to agents over MCP.
| Group | Tools | Required feature | What it does |
|---|---|---|---|
| Built-in context | context_whoami | none | Returns the current auth context — tenantId, organizationId, userId, isSuperAdmin, and the granted features. Use it to confirm your scope. |
| Code Mode meta-tools | search, execute | ai_assistant.view | Discover and call the full API surface from JavaScript (see below). |
| Module tool packs | 67 typed tools | per-tool requiredFeatures | Typed defineAiTool tools such as customers.list_people, customers.list_companies, customers.list_deals, catalog.list_products, catalog.list_offers, catalog.search_products, search.hybrid_search, search.get_record_context, attachments.list_record_attachments, attachments.read_attachment, meta.list_agents, meta.describe_agent, and more. |
Each module tool declares its own requiredFeatures, so a tool appears only if the API key's ACL covers it. In dev the tool list is filtered by the key's features at startup; in production each call is checked. Wildcard grants (module.*, *) match concrete required features.
Reaching any API endpoint
The boot log line Registered N API route manifests for API-backed tools refers to an internal registry that API-backed tools (like customers.list_companies) use to call documented routes in-process. Those ~500+ routes are not exposed as hundreds of individual MCP tools.
To reach any endpoint from MCP, use the two Code Mode meta-tools — search to discover, execute to call:
-
search— the AI writes a JavaScript arrow function that queries the OpenAPIspec(paths + entity schemas) to discover endpoints and shapes:search({ code: 'async () => Object.keys(spec.paths).filter(p => p.includes("customer"))' }) -
execute— the AI writes JS that callsapi.request({ method, path, query, body })against the live app to read or write data:execute({ code: 'async () => api.request({ method: "GET", path: "/api/customers/companies", query: { city: "New York" } })' })
execute runs in a node:vm sandbox: fetch, require, process, and fs are blocked, there is a 30-second timeout, and a maximum of 50 API calls per execution. Between search and execute, the entire documented API surface is reachable from MCP — via code, not via one-tool-per-route.
MCP serves tools, not agents. The typed AI agents (defineAiAgent definitions such as catalog.catalog_assistant, catalog.merchandising_assistant, and the customers agents) are a separate in-app surface served by the Next.js app at POST /api/ai_assistant/ai/chat?agent=<module>.<agent> and listed at GET /api/ai_assistant/ai/agents. The MCP server does not expose agents as a connectable surface — you cannot "run an agent" end-to-end over MCP.
You can still see the agent catalog from within MCP: meta.list_agents enumerates the agents the caller is allowed to invoke, and meta.describe_agent returns an agent's details. And because every tool an agent is allowed to call is itself in the MCP tool list, an MCP client already has the full underlying toolset — just not the agent orchestration layer.
Extending the MCP server
There are two ways to add tools that become available over MCP.
1. Typed tool packs (preferred)
Define the tool with defineAiTool from @saasframe/ai-assistant inside a module's ai-tools/ directory and export it from the module's ai-tools.ts. These typed tools are also what AI agents reference in their allowedTools.
import { z } from 'zod'
import { defineAiTool } from '@saasframe/ai-assistant'
export const archiveCustomer = defineAiTool({
name: 'customers.archive_customer',
description: 'Archive a customer by id.',
inputSchema: z.object({
customerId: z.string().uuid(),
reason: z.string().optional(),
}),
requiredFeatures: ['customers.manage'],
isMutation: true,
async handler(input, ctx) {
const result = await ctx.someService.archive(input.customerId, input.reason)
return { archived: true, customerId: result.id }
},
})
A typed tool requires:
name— namespaced likemymodule.do_thing.description.inputSchema— a Zod schema.requiredFeatures— an array of ACL feature ids.isMutation—truefor writes.handler(input, ctx)— an async function returning a serializable object.
2. Low-level registerMcpTool
For a directly-registered MCP tool, call:
registerMcpTool(
{
name: 'mymodule.do_thing',
description: 'Do the thing.',
inputSchema: z.object({ id: z.string().uuid() }),
requiredFeatures: ['mymodule.manage'],
handler: async (input) => ({ ok: true }),
},
{ moduleId: 'mymodule' },
)
MUST rules when adding a tool
- Set
requiredFeaturesfor anything that reads or writes tenant data — never leave it empty for data tools. - Use Zod for
inputSchema— never raw JSON Schema. - Return a serializable object from the handler.
- Route every mutation through the mutation-approval path (
prepareMutation). - Add the feature ids to the module's
acl.tsand grant them insetup.tsdefaultRoleFeatures.
After adding or changing tools, run yarn generate (so the generated tool registry picks them up) and restart the MCP server.
The API key's ACL still gates visibility — a new tool only appears for keys whose roles grant its requiredFeatures.
API-backed tools
API-backed tools are built with defineApiBackedAiTool, which wraps a documented API route.
The tool's requiredFeatures MUST be a superset of the underlying route's requireFeatures. If they don't cover it, the operation runner rejects the call with:
AI tool "<name>" requiredFeatures do not cover route <METHOD> <path> requiredFeatures
Fix it by adding the route's feature(s) to the tool — never by weakening the route.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Only context_whoami is listed (just 1 tool) | The API key has no roles/features, so RBAC strips every tool that requires features | Bind the key to a role like admin (CLI --roles admin or via Settings → API Keys) and reconnect |
Tool error: AI tool "<name>" requiredFeatures do not cover route GET <path> requiredFeatures | An API-backed tool declares fewer features than the route it calls | Add the route's required feature(s) to the tool's requiredFeatures |
Operation runner manifest unavailable: No API route manifest registered | The standalone server didn't register the API route manifest | Ensure you are on a build where the MCP boot registers it; update or rebuild if missing |
ERR_MODULE_NOT_FOUND: Cannot find package '@/.saasframe' … then Registered 0 module-contributed AI tools (historical) | The generated registry was imported through the Next.js-only @/ path alias, which a standalone Node process can't resolve | Update to a build where the loader resolves the generated registry from disk for standalone servers |
MCP_SERVER_API_KEY not set badge in the app | Only concerns the production mcp:serve / in-app web-chat path | Ignore it for mcp:dev + Claude Code; set MCP_SERVER_API_KEY only if you run yarn mcp:serve |
execute / API-backed tools fail to reach data | The Next.js app isn't running | Run yarn dev (the app on port 3000; override the base URL via APP_URL / NEXT_PUBLIC_APP_URL) |
| Claude Code doesn't see updated tools after a change | Stale server or stale connection | yarn generate, restart yarn mcp:dev, then reconnect via /mcp in Claude Code |
Notes
- Only one tool listed? This almost always means the API key carries no features.
context_whoamihas norequiredFeatures, so it survives RBAC filtering while every data tool is stripped. Grant the key a role and reconnect. - Manifest / module-not-found errors are build-level problems with the standalone server, not configuration mistakes — the fix is to update or rebuild to a version where the MCP boot registers the API route manifest and resolves the generated registry from disk. A healthy boot log shows
Registered N API route manifests for API-backed tools. MCP_SERVER_API_KEYis unrelated to themcp:dev+ Claude Code workflow. It only matters for the productionyarn mcp:servepath and the in-app web chat.
curl http://localhost:3001/healthchecks the server is up.- In Claude Code,
/mcpshows the current connection state. yarn saasframe ai_assistant mcp:list-tools --verboselists all registered tools and their required features.