Skip to main content

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:

LayerMCP codepathsResponsibility
Domaindomain/mcp-principal.interface.ts, domain/mcp-tool.interface.tsPrincipal shape, scopes, tool contract, registry error types
Applicationapplication/mcp-session.service.ts, application/mcp-token.service.ts, application/mcp-tools-initializer.service.tsSession lifecycle, Firebase-to-MCP token exchange, runtime tool registration
Infrastructureinfrastructure/mcp-prompts.handler.ts, infrastructure/mcp-resources.handler.ts, infrastructure/mcp-api-key.repository.tsPrompt/resource registration and Kysely-backed API key lookup
Interfacesinterfaces/mcp.controller.ts, mcp-token.controller.ts, guardsHTTP transport, auth guards, request/response parsing
Tool adapterstools/**/*.tsAdapter 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:

  1. Tool factories create McpToolDefinition objects.
  2. McpToolsInitializer.onModuleInit() registers those definitions into ToolRegistry.
  3. McpSessionService.buildServer() calls ToolRegistry.listFor(principal).
  4. Only visible tools are registered on that session's McpServer.
  5. 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 identifier
  • description: concise instruction for the AI client, including when to prefer related tools
  • requiredScopes: narrowest scope set that matches the behavior
  • inputSchema: Zod fields with .describe(...) text for AI clients
  • handler: adapter code that receives validated input and McpPrincipal
  • operatorOnly, multiBusinessOnly, or skipTenantCheck only when the visibility model needs them

Schema behavior to remember:

  • McpSessionService wraps inputSchema in z.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_business and set_active_tenant must define businessId in inputSchema; 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:

ScopeCurrent tool families
pos:readproducts, inventory, orders, sales, purchases, FEL reads, business context
pos:writecreate_order, void_transaction
reports:readreport tools
psa:readImplementation Portal tools
pos:intentssummary/health intent tools

Visibility is filtered at session initialization by ToolRegistry.listFor(principal):

  • operatorOnly tools are visible only to platform_operator.
  • multiBusinessOnly tools are visible only when authorizedBusinessIds.length > 1.
  • tools requiring pos:intents are never visible to tenant_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_business accepts a business already present in authorizedBusinessIds
  • set_active_tenant is 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:

  1. McpApiKeyGuard checks V1 API keys (fp_mcp_...) by hash lookup.
  2. McpTokenGuard checks V2 MCP JWTs issued by POST /mcp/token.

V2 token exchange is not a full OAuth authorization-code flow. The current flow is:

  1. client obtains a Firebase ID token
  2. POST /mcp/token validates Firebase and resolves active business_user memberships
  3. backend returns an MCP JWT
  4. 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

  1. Confirm the owning module already exposes an application service/use case or repository port for the behavior.
  2. Add the factory or extend an existing factory under apps/backend/src/mcp/tools/.
  3. Keep tenant scoping explicit by passing principal.activeBusinessId into the owning service or query.
  4. Choose the narrowest scope and visibility flags.
  5. Register the tool in McpToolsInitializer.
  6. Add tests for:
    • factory handler success/error behavior
    • visibility in ToolRegistry.listFor
    • missing scope and tenant-context rejection where applicable
  7. 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_business is missing for a merchant: the MCP token resolved only one authorized business.
  • Intent tools missing: caller lacks pos:intents, or the role is tenant_developer.
  • Tool returns the wrong tenant data: verify the handler uses principal.activeBusinessId and does not accept arbitrary businessId input.
  • log_hours fails for an API key: older API keys may have userId = null; only platform_operator can pass a userId override.
  • 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.