MCP Tool Authoring Guide
Source-backed guide for maintaining FlowPOS MCP tools in apps/backend/src/mcp/.
Use this page when adding a tool, changing tool copy, or debugging why a tool is missing from an AI client. For endpoint behavior and the full runtime catalog, see MCP API Reference. For production troubleshooting, see MCP Session and Auth Troubleshooting Runbook.
Intent
MCP is an inbound adapter over existing FlowPOS capabilities. Tool code should expose a small, AI-friendly interface without moving business rules out of their owning modules.
Keep these boundaries explicit:
| Layer | MCP codepaths | Responsibility |
|---|---|---|
| Domain | domain/mcp-principal.interface.ts, domain/mcp-tool.interface.ts | Principal shape, scopes, tool contract, registry error types |
| Application | application/mcp-session.service.ts, application/mcp-token.service.ts, application/mcp-tools-initializer.service.ts | Session lifecycle, Firebase-to-MCP token exchange, runtime tool registration |
| Infrastructure | infrastructure/mcp-prompts.handler.ts, infrastructure/mcp-resources.handler.ts, infrastructure/mcp-api-key.repository.ts | Prompt/resource registration and Kysely-backed API key lookup |
| Interfaces | interfaces/mcp.controller.ts, mcp-token.controller.ts, guards | HTTP transport, auth guards, request/response parsing |
| Tool adapters | tools/**/*.ts | Adapter glue from AI tool input to existing application services/use cases |
Do not document or implement provider HTTP, database, or SDK details as MCP domain behavior. Tool handlers should call the owning module's application service or repository port and return a compact result.
Runtime registration flow
Current runtime registration is driven by McpToolsInitializer:
- Tool factories create
McpToolDefinitionobjects. McpToolsInitializer.onModuleInit()registers those definitions intoToolRegistry.McpSessionService.buildServer()callsToolRegistry.listFor(principal).- Only visible tools are registered on that session's
McpServer. - Tool closures receive the current session principal so context-switching tools can update
activeBusinessId.
A factory that is not registered in McpToolsInitializer is not visible at runtime, even if the file exists.
Tool definition checklist
Every tool definition should include:
name: stable snake_case public identifierdescription: concise instruction for the AI client, including when to prefer related toolsrequiredScopes: narrowest scope set that matches the behaviorinputSchema: Zod fields with.describe(...)text for AI clientshandler: adapter code that receives validated input andMcpPrincipaloperatorOnly,multiBusinessOnly, orskipTenantCheckonly when the visibility model needs them
Schema behavior to remember:
McpSessionServicewrapsinputSchemainz.object(...)when registering the tool with the MCP SDK.- If a tool has no
inputSchema, unknown arguments are stripped before the handler sees them. - Context-switching tools such as
set_active_businessandset_active_tenantmust definebusinessIdininputSchema; otherwise the handler receives an empty object. - Keep
.describe(...)text source-aligned because AI clients use it as public interface guidance.
Example pattern from createProductsTools:
{
name: "get_products",
description:
"List products in the active business, with optional search and category filters. Each result includes productName, sku, price, and categoryName.",
requiredScopes: ["pos:read"],
inputSchema: {
search: z.string().optional().describe("Full-text search across name, SKU, and barcode"),
page: z.number().int().min(1).optional().describe("Page number (default: 1)"),
limit: z.number().int().min(1).max(100).optional().describe("Page size (default: 20)"),
},
async handler(input, principal) {
// Call the owning application service using principal.activeBusinessId.
},
}
Prefer toolSuccess, toolError, and toolValidationError from tools/tool-result.helpers.ts so responses keep the standard MCP shape:
{
"content": [{ "type": "text", "text": "{...json...}" }]
}
Validation or business failures should return isError: true; avoid throwing unless the transport should fail.
Scope and visibility rules
Scopes are defined in McpScope:
| Scope | Current tool families |
|---|---|
pos:read | products, inventory, orders, sales, purchases, FEL reads, business context |
pos:write | create_order, void_transaction |
reports:read | report tools |
psa:read | Implementation Portal tools |
pos:intents | summary/health intent tools |
Visibility is filtered at session initialization by ToolRegistry.listFor(principal):
operatorOnlytools are visible only toplatform_operator.multiBusinessOnlytools are visible only whenauthorizedBusinessIds.length > 1.- tools requiring
pos:intentsare never visible totenant_developer. - generic missing scopes hide the tool from
tools/list.
Most tools require an active business. Use skipTenantCheck: true only for tools that set or inspect context without needing tenant-scoped data, such as set_active_business or set_active_tenant.
Context-switching tools mutate only the MCP session principal:
set_active_businessaccepts a business already present inauthorizedBusinessIdsset_active_tenantis platform-operator only- neither updates Firebase claims,
business_user, API key records, or persisted tenant data
After role, scope, or membership changes, clients must initialize a new MCP session because the visible tool set is calculated at session start.
Auth flow constraints
McpAuthGuard accepts either credential type on the same /mcp endpoint:
McpApiKeyGuardchecks V1 API keys (fp_mcp_...) by hash lookup.McpTokenGuardchecks V2 MCP JWTs issued byPOST /mcp/token.
V2 token exchange is not a full OAuth authorization-code flow. The current flow is:
- client obtains a Firebase ID token
POST /mcp/tokenvalidates Firebase and resolves activebusiness_usermemberships- backend returns an MCP JWT
- client uses
Authorization: Bearer <mcp-jwt>on/mcp
The well-known endpoints publish metadata for discovery, but the backend does not currently expose /mcp/oauth/authorize or /mcp/oauth/token.
Adding a new tool safely
- Confirm the owning module already exposes an application service/use case or repository port for the behavior.
- Add the factory or extend an existing factory under
apps/backend/src/mcp/tools/. - Keep tenant scoping explicit by passing
principal.activeBusinessIdinto the owning service or query. - Choose the narrowest scope and visibility flags.
- Register the tool in
McpToolsInitializer. - Add tests for:
- factory handler success/error behavior
- visibility in
ToolRegistry.listFor - missing scope and tenant-context rejection where applicable
- Update MCP API Reference with the tool name, purpose, arguments, defaults, constraints, and scope.
Do not add cross-business aggregate behavior to existing single-business tools. Cross-business tools require a separate product/security decision because current MCP sessions operate on one active business at a time.
Tenant scoping checklist
For list and aggregate tools, MCP wrapper code should pass
principal.activeBusinessId directly into the owning service/repository call.
For detail-by-ID tools, do not assume the UUID alone is enough. Either:
- call an owning service method that enforces business scoping internally, or
- add/choose a repository method that accepts both the record ID and
activeBusinessId.
If an existing detail tool delegates only by ID, document it as current implementation behavior in the API reference and verify the owning service before changing the tool copy.
Common pitfalls
- Tool exists in a file but is absent from
tools/list: it is not registered or the principal failed visibility checks. set_active_businessis missing for a merchant: the MCP token resolved only one authorized business.- Intent tools missing: caller lacks
pos:intents, or the role istenant_developer. - Tool returns the wrong tenant data: verify the handler uses
principal.activeBusinessIdand does not accept arbitrarybusinessIdinput. log_hoursfails for an API key: older API keys may haveuserId = null; onlyplatform_operatorcan pass auserIdoverride.- Prompt/resource text changed but tool behavior did not: prompt and resource copy lives in
packages/global/consts/mcp-capabilities.consts.ts; it does not bypass tool visibility or scopes.