MCP4Acumatica -- Architecture

Detailed architecture documentation for the MCP4Acumatica.

Overview

The MCP4Acumatica is a remote Model Context Protocol (MCP) server that runs on Cloudflare Workers. It connects AI assistants (Claude, or any MCP-compatible client) to an Acumatica ERP 2025 R2 instance via the contract-based REST API.

┌─────────────────────┐
│  Claude / MCP Client│
│  (claude.ai, CLI,   │
│   Desktop, API)     │
└─────────┬───────────┘
          │ MCP over streamable-http
          │ (Bearer token auth)
          ▼
┌─────────────────────────────────────────────┐
│  Cloudflare Worker                          │
│                                             │
│  ┌──────────────────────────────────┐       │
│  │  OAuthProvider                   │       │
│  │  (@cloudflare/workers-oauth-     │       │
│  │   provider)                      │       │
│  │                                  │       │
│  │  Endpoints:                      │       │
│  │  /register   - DCR               │       │
│  │  /authorize  - Start OAuth flow  │       │
│  │  /callback   - Handle redirect   │       │
│  │  /token      - Issue tokens      │       │
│  └──────────────────────────────────┘       │
│                                             │
│  ┌──────────────────────────────────┐       │
│  │  Hono App (defaultHandler)       │       │
│  │  AcumaticaAuthHandler            │       │
│  │                                  │       │
│  │  Routes:                         │       │
│  │  /authorize  - Acumatica redirect│       │
│  │  /callback   - Token exchange +  │       │
│  │               role gate          │       │
│  │  /consent    - AI data consent   │       │
│  │  /health     - Health check      │       │
│  │  /           - Landing page      │       │
│  └──────────────────────────────────┘       │
│                                             │
│  ┌──────────────────────────────────┐       │
│  │  McpAgent Durable Object         │       │
│  │  AcumaticaMcpServer (apiHandler) │       │
│  │                                  │       │
│  │  /mcp  - MCP protocol endpoint   │       │
│  │  /sse  - SSE transport           │       │
│  │                                  │       │
│  │  48 tools registered in init()   │       │
│  └──────────────┬───────────────────┘       │
│                 │                            │
│  ┌──────────────┴───────────────────┐       │
│  │  KV Namespaces                   │       │
│  │  TOKEN_STORE - per-user tokens   │       │
│  │               + OAuth state      │       │
│  └──────────────────────────────────┘       │
└─────────────────────┬───────────────────────┘
                      │ HTTPS (Bearer token)
                      ▼
┌─────────────────────────────────────────────┐
│  Acumatica 25R2 SaaS Instance              │
│  Contract-Based REST API                    │
│  /entity/Default/25.200.001/...            │
│                                             │
│  Per-user access based on Acumatica roles   │
└─────────────────────────────────────────────┘

Components

1. OAuthProvider

The @cloudflare/workers-oauth-provider package wraps the entire Cloudflare Worker. It acts as an OAuth 2.1 Authorization Server for MCP clients (Claude), providing:

This layer is transparent to the MCP tools. By the time a request reaches the McpAgent, it already has a valid, authenticated session.

2. AcumaticaAuthHandler (Hono App)

A Hono application that handles the Acumatica OAuth 2.0 authorization code flow:

  1. /authorize -- Builds the Acumatica OAuth authorization URL with scope=api openid profile email offline_access and redirects the user to Acumatica's login page
  2. /callback -- Receives the authorization code from Acumatica, exchanges it for access + refresh tokens, identifies the user via OIDC userinfo, performs the role gate check (see below), and redirects to the consent page
  3. /consent (GET) -- Displays the consent interstitial page explaining AI data processing, audit logging, and field redaction
  4. /consent (POST) -- User acknowledges the consent; tokens are stored in KV and the MCP OAuth flow completes
  5. /health -- Returns server status
  6. / -- Landing page

3. McpAgent Durable Object (AcumaticaMcpServer)

A Durable Object that extends McpAgent from the agents SDK. Each MCP session gets its own DO instance with:

The DO binding must be named MCP_OBJECT (required by the agents SDK's McpAgent.serve()).

A second Durable Object, TokenManager (binding TOKEN_MANAGER), owns each user's Acumatica token. It is keyed by idFromName(username), so there is exactly one instance per user globally and all of a user's concurrent sessions share it. This serializes token refresh: IdentityServer rotates the refresh token on every use, and without a single owner two sessions could POST the same refresh token concurrently — one wins, the other gets a 4xx and is wrongly treated as dead. Routing all refreshes through the per-user DO makes that race impossible. The DO's storage is the authoritative token copy; KV is a write-through backup.

4. AcumaticaClient

HTTP client for the Acumatica contract-based REST API. Features:

5. KV Namespaces

Note: Tool handlers and shared libraries access storage through the IKeyValueStore abstraction (AppEnv.store), not raw KVNamespace. On Cloudflare, this is backed by KV via CloudflareKVStore. The raw KV bindings below are used directly only by the auth handler and admin handler (which are Cloudflare-specific infrastructure).

Both bindings point to the same physical KV namespace (one namespace, two bindings).

Binding Purpose Key Pattern TTL
TOKEN_STORE Per-user Acumatica OAuth tokens user_token:{username} None (refreshed on expiry)
TOKEN_STORE Temporary OAuth state during login flow acumatica_state:{state} 10 minutes
TOKEN_STORE Pending consent data during login flow consent:{id} 5 minutes
TOKEN_STORE Cached metadata (entity schemas, GI lists) cache:{key} 1–24 hours
OAUTH_KV Used internally by @cloudflare/workers-oauth-provider for client registrations and authorization codes Managed by library Managed by library

OAuth Flow

MCP Client (Claude)                Worker                      Acumatica
       │                             │                             │
       │  1. Connect to /mcp         │                             │
       │──────────────────────────>  │                             │
       │                             │                             │
       │  2. 401 Unauthorized        │                             │
       │  <──────────────────────────│                             │
       │                             │                             │
       │  3. POST /register (DCR)    │                             │
       │──────────────────────────>  │                             │
       │  <── client_id, secret  ────│                             │
       │                             │                             │
       │  4. GET /authorize          │                             │
       │──────────────────────────>  │                             │
       │                             │  5. Redirect to Acumatica   │
       │  <──────── 302 ────────────────────────────────────────>  │
       │                             │                             │
       │                             │        User logs in         │
       │                             │                             │
       │                             │  6. Redirect to /callback   │
       │  <──────── 302 ──────────────────────────────────────── │
       │                             │                             │
       │                             │  7. Exchange code for token │
       │                             │──────────────────────────>  │
       │                             │  <── access + refresh token │
       │                             │                             │
       │                             │  8. OIDC userinfo lookup    │
       │                             │──────────────────────────>  │
       │                             │  <── username, display name │
       │                             │                             │
       │                             │  9. Role gate: query        │
       │                             │     MCPAccess canary GI     │
       │                             │──────────────────────────>  │
       │                             │  <── 200 (has role) or      │
       │                             │      403 (denied)           │
       │                             │                             │
       │                             │  10. If denied → 403 page   │
       │                             │  11. If allowed → /consent  │
       │  <── Consent interstitial ──│                             │
       │                             │                             │
       │     User acknowledges       │                             │
       │──────────────────────────>  │                             │
       │                             │  12. Store token in KV      │
       │                             │  13. Complete OAuth flow    │
       │  <── MCP session active ────│                             │
       │                             │                             │
       │  14. Tool calls via /mcp    │                             │
       │──────────────────────────>  │  15. API call with token    │
       │                             │──────────────────────────>  │
       │                             │  <── JSON response ─────── │
       │  <── MCP tool result ───────│                             │

Key Points


Security Model

Authentication

  1. MCP clients authenticate via OAuth 2.1 (DCR + authorization code flow)
  2. The Worker authenticates with Acumatica via per-user OAuth tokens
  3. Users log in with their Acumatica credentials (or SSO configured on the Acumatica instance)

Authorization & Access Control

Acumatica's role-based access control governs what data each user can access -- if a user can't see a record in Acumatica's UI, they can't access it through the MCP server. On top of that, the MCP server adds its own access control layer:

Role Gate (Canary GI)

Before a user can access the MCP server, the /callback handler checks whether they belong to a specific Acumatica role. This is implemented using a canary Generic Inquiry (GI) approach:

  1. A trivial GI named MCPAccess is created in Acumatica (SM208000). Its content is irrelevant -- it can be any single column.
  2. The MCPAccess GI is assigned only to the MCP Access role in Acumatica.
  3. The GI is enabled for OData exposure.
  4. During login, the server queries the GI via OData: GET /t/{tenant}/api/odata/gi/MCPAccess?$top=1
  5. If the response is 200, the user has the role and may proceed.
  6. If the response is 403, the user does not have the role and sees an access denied page directing them to contact their Acumatica administrator.

This approach avoids exposing user/role membership data -- the GI content itself is never used. It works reliably across Acumatica SaaS instances where direct user/role API endpoints are not available.

The required role name defaults to MCP Access and is configurable via the ACUMATICA_MCP_ROLE environment variable.

If the canary GI query returns 404 or a 5xx error (rather than 200 or 403), the server treats it as a misconfiguration -- not a permission denial. The user sees a "Configuration Error" page pointing at the likely cause (missing GI, wrong tenant, unreachable instance, OData not enabled on the GI). The event is logged as login_denied with reason: role_check_misconfigured so an admin sees why. Previously all non-200 responses looked identical to "user missing role", which hid real outages behind an access-denied screen.

Acumatica setup required:

Consent Interstitial

Users who pass the role gate are shown a consent page before the MCP session activates. The page explains that:

The user must click "I Understand -- Continue" to proceed. Consent acknowledgment is logged as an audit event.

Data Protection

Sensitive Field Redaction

The redactFields() utility (src/lib/redact.ts) recursively walks every Acumatica API response and replaces values of fields whose names match sensitive patterns with [REDACTED].

Built-in patterns (case-insensitive, matched as substrings of field names): SSN, SocialSecurity, TaxRegistrationID, TaxID, BankAccount, RoutingNumber, IBAN, SWIFT, CreditCard, CardNumber, Password, Secret, Salary, PayRate, HourlyRate, AnnualRate, BirthDate, DateOfBirth, DOB

Configuration:

When fields are redacted, a structured log entry is emitted with the tool name, username, and list of redacted field paths.

OData $filter Pass-Through

The list/query tools (acumatica_list_entities, acumatica_run_inquiry) accept a filterExpression parameter that is passed to Acumatica verbatim as $filter=.... The server does not parse, rewrite, or sanitize the expression. This is intentional and safe because:

  1. Record access is governed by the per-user Acumatica role, not by this server. Whatever a filter can find, the user could already find via the same API or the Acumatica UI.
  2. Entity exposure is denylisted (src/tools/entity-list.ts). The generic lister refuses a fixed set of auth/credential entities (User, UserRole, etc.) regardless of filter.
  3. Nested $expand paths are rejected. A caller cannot traverse more than one navigation-property level, so sensitive sub-records are not reachable via a cleverly chained filter.
  4. Sensitive fields are redacted on the way out (see previous section), so even a successful filter cannot return SSNs, bank accounts, salary, etc.

One consequence operators should be aware of: a filter can be used as a blind-enumeration oracle for data the user is already permitted to read (e.g. substringof('needle', SomeField) to probe values). This is within the user's role and is logged via tool_invocation with the filter expression captured for audit. If that exposure is unacceptable for a particular deployment, disable the lister and use only the per-entity acumatica_get_* tools.

Audit Logging

All tool invocations and security events are logged as structured JSON via console.log (viewable with npx wrangler tail). Three log types are emitted:

Log Type Events Key Fields
tool_invocation Every MCP tool call tool, username, endpoint, status, duration
auth_event login_success, login_denied, consent_accepted eventType, username, reason (if denied)
field_redaction When sensitive fields are redacted from a response tool, username, redactedFields, redactedCount

Rate Limiting

Multiple safeguards protect the Acumatica instance:

Limit Value Scope
Concurrent requests 3 Per user
Requests per minute 40 Per user
Max records per query ($top) 1000 (configurable) Per request

When a rate limit is exceeded, the tool returns a friendly error message asking the user to wait.

Pagination Refusal Semantics

The list/query tools (acumatica_list_entities, acumatica_run_inquiry, acumatica_list_generic_inquiries) do not support pagination. When a response hits the ACUMATICA_MAX_RECORDS cap, the tool returns a structured envelope instead of a bare array:

{
  "results": [...],
  "truncated": true,
  "recordsReturned": 1000,
  "recordLimit": 1000,
  "paginationSupported": false,
  "actionRequired": "Results were truncated at 1000 records and this tool does NOT support pagination. Do NOT call this tool again... Instead, stop and ask the user to narrow their request by providing a more specific filterExpression..."
}

This turns the "don't paginate" rule into a semantic contract the model can read and act on — it is instructed to surface a clarifying request to the user rather than issue more tool calls. No server-side cooldown is enforced; the contract is the mechanism.

Admin Console

A web-based admin interface at /docs/admin for managing the MCP server without the wrangler CLI.

Long-Term Log Retention (R2 + Logpush)

Cloudflare Logpush captures Workers Trace Events and writes NDJSON files to an R2 bucket for permanent retention.

Runtime Config (KV-Backed)

Settings can be changed without redeploying via the admin console or direct KV writes.


Tool Architecture

Tool Registration

All 48 tools are registered in the init() method of AcumaticaMcpServer. Each tool has:

  1. Name -- e.g., acumatica_get_customer
  2. Description -- Human-readable description for the MCP client
  3. Zod schema -- Parameter validation (MUST use simple types only)
  4. Handler -- Async function that calls the Acumatica API

Tool Execution Flow

MCP Client sends tool call
       │
       ▼
AcumaticaMcpServer.init() registered handler
       │
       ▼
callTool() wrapper
       │
       ├── Error handling + field redaction
       │
       ▼
Tool handler (e.g., handleGetCustomer)
       │
       ▼
AcumaticaClient.get()
       │
       ├── withRateLimit() check
       ├── getAcumaticaTokenForUser() from store
       ├── fetch() to Acumatica API
       ├── Retry on 401
       ├── logToolInvocation() audit log
       └── Return JSON
       │
       ▼
unwrapFields() strips {value: X} wrappers
       │
       ▼
MCP response: { content: [{ type: "text", text: JSON }] }

Tool Categories

Category Count Description
Utility/Discovery 6 Schema discovery, entity listing, generic inquiries, GI discovery, cache management
Read-Only Lookups 38 Single-record lookups by key across 10 modules

Zod Schema Constraint

MCP tool parameter schemas must use only simple Zod types:

Complex types (z.record(), z.unknown(), z.number()) cause MCP SDK JSON Schema serialization failures and tools won't appear in client discovery. For numeric parameters, use z.string() with parseInt() in the handler.


File Structure

src/
├── index.ts                       # Entry point, OAuthProvider + McpAgent DO
├── admin/
│   └── admin-handler.ts           # Admin console: auth, settings, log viewer
├── auth/
│   ├── acumatica-auth-handler.ts  # Hono app: OAuth flow, health, landing
│   └── acumatica-oauth.ts         # Token retrieval + refresh (via AppEnv.store)
├── lib/
│   ├── acumatica-client.ts        # HTTP client, unwrapFields()
│   ├── config.ts                  # KV-backed runtime config (via IKeyValueStore)
│   ├── kv-store.ts                # IKeyValueStore interface
│   ├── metadata-cache.ts          # KV-backed cache (via IKeyValueStore)
│   ├── rate-limiter.ts            # Concurrent + per-minute rate limits
│   └── logger.ts                  # Structured JSON audit logging
├── platform/
│   └── cloudflare-kv-store.ts     # CF adapter for IKeyValueStore
├── tools/                         # 42 tools across 32 handler files
│   ├── entity-list.ts             # acumatica_list_entities
│   ├── entity-schema.ts           # acumatica_describe_entity
│   ├── generic-inquiries.ts       # acumatica_run_inquiry
│   ├── generic-inquiry-discovery.ts # acumatica_list_generic_inquiries, _describe_inquiry
│   ├── clear-cache.ts             # acumatica_clear_cache
│   ├── customers.ts               # acumatica_get_customer
│   ├── vendors.ts                 # acumatica_get_vendor
│   ├── ... (29 more handler files)
│   └── warehouses.ts              # acumatica_get_warehouse
├── types/
│   └── acumatica.ts               # TypeScript types, AppEnv, Env, AuthProps
docs/
├── tool-reference.md              # Complete tool specification
├── example-prompts.md             # Example prompts by use case
├── odata-filtering.md             # OData query parameter guide
└── architecture.md                # This file

Deployment

Infrastructure

Component Service
Compute Cloudflare Workers
State Durable Objects (per-session)
Storage Cloudflare KV (tokens, OAuth state)
DNS/TLS Cloudflare (automatic)

Configuration

Type Location Example
Environment variables wrangler.jsonc vars ACUMATICA_URL, ACUMATICA_ENDPOINT_VERSION
Secrets wrangler secret put ACUMATICA_CLIENT_ID, ACUMATICA_CLIENT_SECRET, COOKIE_ENCRYPTION_KEY
KV bindings wrangler.jsonc kv_namespaces TOKEN_STORE, OAUTH_KV (same namespace)
DO binding wrangler.jsonc durable_objects MCP_OBJECT (must be this name)

Deploy Command

npx wrangler deploy

Storage Abstraction Layer

The MCP server uses a platform-agnostic storage interface to decouple tool handlers from Cloudflare-specific APIs, enabling future self-hosted deployments on Node.js or other platforms.

IKeyValueStore Interface

Defined in src/lib/kv-store.ts, this interface provides four operations:

Method Signature Used By
get (key: string) => Promise<string | null> Token retrieval, config read, cache lookup
put (key: string, value: string, options?: { expirationTtl?: number }) => Promise<void> Token storage, config write, cache write
delete (key: string) => Promise<void> Config delete, cache invalidation
list (options: { prefix: string; cursor?: string }) => Promise<{ keys, list_complete, cursor }> Cache clearing (enumerate + bulk delete)

AppEnv vs Env

Type Purpose Used By
AppEnv Portable: Acumatica config strings + store: IKeyValueStore All 44 tool handlers, AcumaticaClient, config.ts, metadata-cache.ts, acumatica-oauth.ts
Env CF-specific: extends AppEnv with TOKEN_STORE, OAUTH_KV, MCP_OBJECT, OAUTH_PROVIDER, R2Bucket index.ts, acumatica-auth-handler.ts, admin-handler.ts

Since Env extends AppEnv, the Cloudflare entry point (index.ts) passes this.env (typed Env) to tool handlers (typed AppEnv) with no cast needed.

Cloudflare Adapter

CloudflareKVStore (src/platform/cloudflare-kv-store.ts) wraps a KVNamespace binding as an IKeyValueStore. It is a thin passthrough -- every method maps 1:1 to the KV API. Initialized in AcumaticaMcpServer.init():

this.env.store = new CloudflareKVStore(this.env.TOKEN_STORE);

Self-Hosting

For self-hosted deployments, implement IKeyValueStore with Redis, SQLite, or an in-memory store, construct an AppEnv from environment variables, and wire up @modelcontextprotocol/sdk directly. See Self-Hosting Guide for details.


Design Decisions

Why Acumatica as sole identity provider?

The initial design used Microsoft Entra ID as a separate identity layer, chained to Acumatica OAuth. This was removed because:

  1. Redundant. Every user must authenticate with Acumatica anyway to get role-based API permissions.
  2. Acumatica supports Entra SSO natively. If configured, users get the Microsoft login experience through Acumatica's own login page.
  3. Simpler. One login, one callback, no Entra secrets to manage.

Why read-only first?

Write operations require careful validation, conflict handling, and business rule enforcement. Starting read-only allows:

  1. Safe exploration and analysis without risk of data corruption
  2. Building trust with Acumatica admins who may be cautious about AI access
  3. Understanding usage patterns before adding write capabilities

Why Durable Objects?

Each MCP session needs persistent state (tool registry, user context). Durable Objects provide:

  1. Per-session isolation
  2. In-memory tool registration (no re-registration per request)
  3. Consistent routing (all requests for a session go to the same DO instance)

Why unwrapFields()?

Acumatica's contract-based REST API wraps every field value as {value: X}. This is verbose and confusing for AI assistants. The unwrapFields() utility recursively strips these wrappers, turning {CustomerName: {value: "Acme Corp"}} into {CustomerName: "Acme Corp"}.

Why AppEnv instead of full Env abstraction?

Rather than abstracting the entire Worker infrastructure (OAuth provider, Durable Objects, auth flow), only the storage layer is abstracted via IKeyValueStore + AppEnv. This is because:

  1. Tools are the reusable part. All 44 tool handlers and the Acumatica client only need config strings and a key-value store. They never touch OAuth, DOs, or R2.
  2. Auth varies fundamentally by platform. A Node.js self-host would use Express + Passport or skip auth entirely. Abstracting the auth handler would create an interface that no two implementations share.
  3. Minimal disruption. Tool handler changes were limited to import swaps (Env -> AppEnv). No function bodies changed.