MCP4Acumatica — Azure App Service Hosting Guide

How to run MCP4Acumatica on Azure App Service (Linux, Node.js) with Azure Table Storage and Azure Blob Storage, instead of Cloudflare Workers.

This is a full alternative host, not a shim. Every capability of the Cloudflare deployment is present: all 49 tools, per-user Acumatica OAuth, the canary-GI access gate, the consent interstitial, sensitive-field redaction, rate limiting, durable audit logs, and transparent re-auth when a refresh token dies.


Why this is a port and not a recompile

The Cloudflare deployment is built on primitives that exist nowhere else, so four of them had to be replaced rather than retargeted:

Cloudflare Azure replacement Where
Workers KV Azure Table Storage src/platform/azure/azure-table-kv-store.ts
R2 buckets Azure Blob Storage (audit logs + schema index) src/platform/azure/azure-blob-store.ts
TokenManager Durable Object in-process coalescing + a Table Storage lease src/platform/azure/locking-token-provider.ts
@cloudflare/workers-oauth-provider a self-hosted OAuth 2.1 server src/oauth/
McpAgent Durable Object (one per session) in-process session map + StreamableHTTPServerTransport src/mcp/session-manager.ts

Everything above the storage line is shared source, not a copy — the tool registry, the redaction and audit pipeline, the access gate, the login pages, and the docs site are single implementations used by both hosts. That is deliberate: the tool descriptions and the redaction rules are the two places where a silent divergence between hosts would do real damage.

The pieces that remain Cloudflare-only are the admin console (src/admin/admin-handler.ts) and Logpush integration. See Not yet ported.


Prerequisites

  1. An Azure subscription with permission to create a resource group, an App Service plan, a web app, and a storage account.
  2. The Azure CLI (az), logged in: az login.
  3. Node.js 20 or newer locally, to build the bundle.
  4. Acumatica-side setup, identical to the Cloudflare deployment — it is not optional, and the server cannot create any of it for you:
    • A Connected Application (SM303010) with the Authorization Code flow enabled. Its redirect URI must be https://<your-app>/callback.
    • The MCPAccess canary Generic Inquiry (SM208000), exposed via OData, readable only by the users who should have AI access. Without it, every login fails the access gate. See architecture.
    • Optionally but strongly recommended, the GI gate feeds (MCPGIs, MCPGIFields) from acumatica/. See generic inquiries.

Note on the Acumatica VM. Your Acumatica instance can run anywhere the App Service can reach it over HTTPS, including an Azure VM. What matters is that the instance's public HTTPS URL is what you configure as ACUMATICA_URL, and that its certificate is valid — the server makes ordinary outbound HTTPS calls and does not skip certificate validation. If Acumatica is reachable only inside a VNet, see Private networking.


1. Create the Azure resources

Adjust the names, then run:

az group create --name mcp4acumatica-rg --location eastus
az storage account create --name mcp4acustore --resource-group mcp4acumatica-rg --location eastus --sku Standard_LRS --min-tls-version TLS1_2 --allow-blob-public-access false
az appservice plan create --name mcp4acumatica-plan --resource-group mcp4acumatica-rg --is-linux --sku B1
az webapp create --name mcp4acumatica --resource-group mcp4acumatica-rg --plan mcp4acumatica-plan --runtime "NODE:20-lts"

Storage account names must be globally unique, 3–24 lowercase alphanumeric characters. The web app name becomes https://<name>.azurewebsites.net.

On the B1 tier: anything smaller (F1/D1) has no Always On and gets recycled aggressively, which drops MCP sessions mid-conversation. B1 is the realistic floor.


2. Configure the app

Generate the encryption key — it must be exactly 64 hex characters, and the server refuses to start otherwise rather than failing later with unreadable "please reconnect" loops:

openssl rand -hex 32

Then set the application settings:

az webapp config appsettings set --name mcp4acumatica --resource-group mcp4acumatica-rg --settings ACUMATICA_URL="https://your-instance.example.com" ACUMATICA_TENANT="YourTenant" ACUMATICA_ENDPOINT_VERSION="25.200.001" ACUMATICA_CLIENT_ID="<from SM303010>" ACUMATICA_CLIENT_SECRET="<from SM303010>" COOKIE_ENCRYPTION_KEY="<the 64 hex chars>" ADMIN_SECRET="<a long random string>" PUBLIC_URL="https://mcp4acumatica.azurewebsites.net" AZURE_STORAGE_ACCOUNT="mcp4acustore" WEBSITE_RUN_FROM_PACKAGE="0"

Turn on Always On and pin the startup command:

az webapp config set --name mcp4acumatica --resource-group mcp4acumatica-rg --always-on true --startup-file "node dist/server.mjs"

Grant the app access to storage (managed identity — recommended)

This is the preferred path: no storage key ever lives in configuration.

az webapp identity assign --name mcp4acumatica --resource-group mcp4acumatica-rg
PRINCIPAL=$(az webapp identity show --name mcp4acumatica --resource-group mcp4acumatica-rg --query principalId -o tsv) && SCOPE=$(az storage account show --name mcp4acustore --resource-group mcp4acumatica-rg --query id -o tsv) && az role assignment create --assignee "$PRINCIPAL" --role "Storage Table Data Contributor" --scope "$SCOPE" && az role assignment create --assignee "$PRINCIPAL" --role "Storage Blob Data Contributor" --scope "$SCOPE"

Role assignments can take a couple of minutes to propagate. If the app logs AuthorizationPermissionMismatch on first boot, wait and restart it.

Alternative — connection string. Simpler, but puts a key in app settings. Set AZURE_STORAGE_CONNECTION_STRING instead of AZURE_STORAGE_ACCOUNT:

az webapp config appsettings set --name mcp4acumatica --resource-group mcp4acumatica-rg --settings AZURE_STORAGE_CONNECTION_STRING="$(az storage account show-connection-string --name mcp4acustore --resource-group mcp4acumatica-rg --query connectionString -o tsv)"

If neither is set the server starts with an in-memory store and says so loudly. Every restart then invalidates all issued MCP tokens and stored Acumatica refresh tokens. That mode is for local development only.


3. Build and deploy

npm ci && npm run build

Deploy the app root — dist/, node_modules/, package.json, and the markdown that the docs site reads:

npm run deploy:azure

That script is a thin wrapper around az webapp deploy; see azure/deploy.sh for what it packages and how to override the app name. For CI, .github/workflows/azure-deploy.yml does the same on push.


4. Point Acumatica at the deployment

In SM303010, set the Connected Application's redirect URI to exactly:

https://mcp4acumatica.azurewebsites.net/callback

Every hostname users connect through needs its own entry — if you later add a custom domain, add that callback URL too.

Scopes are not configured on the Connected Application (SM303010 has no scope field). The server requests api openid profile email offline_access in the authorize URL. offline_access is required: without it Acumatica issues no refresh token and every session dies when its ~1-hour access token expires.


5. Verify

Health check, which also reports which storage backend is live:

curl -s https://mcp4acumatica.azurewebsites.net/health

Full configuration diagnostics — this probes instance reachability, OIDC discovery, the Connected Application credentials, the tenant OData path, and the contract endpoint version. It requires ADMIN_SECRET because the results name which settings are missing:

curl -s -H "Authorization: Bearer $ADMIN_SECRET" https://mcp4acumatica.azurewebsites.net/preflight

Confirm the OAuth server is discoverable:

curl -s https://mcp4acumatica.azurewebsites.net/.well-known/oauth-authorization-server

Then add https://mcp4acumatica.azurewebsites.net/mcp as a custom connector in Claude and complete the Acumatica login. Live logs:

az webapp log tail --name mcp4acumatica --resource-group mcp4acumatica-rg

Configuration reference

Required

Setting Notes
ACUMATICA_URL Instance base URL, no trailing slash
ACUMATICA_TENANT Tenant/login company, used for the OData GI path
ACUMATICA_CLIENT_ID From the Connected Application (SM303010)
ACUMATICA_CLIENT_SECRET From the Connected Application
COOKIE_ENCRYPTION_KEY Exactly 64 hex chars. Encrypts refresh tokens at rest — rotating it invalidates every stored token

Storage (one of)

Setting Notes
AZURE_STORAGE_ACCOUNT Account name; uses managed identity. Recommended
AZURE_STORAGE_CONNECTION_STRING Takes precedence if both are set
AZURE_TABLE_NAME Default mcp4acumatica
AZURE_LOGS_CONTAINER Default mcp4acumatica-logs, created if absent
AZURE_INDEX_CONTAINER Default mcp4acumatica-index, never auto-created

Optional

Setting Default Notes
PUBLIC_URL derived per request Pins the OAuth issuer and callback origin. Recommended in production
PORT 8080 App Service sets this
ACUMATICA_ENDPOINT_VERSION 25.200.001 Contract API version
ACUMATICA_ENDPOINT_NAME Default Only for a custom endpoint (SM207060)
ACUMATICA_MAX_RECORDS 1000 Row cap per query
ACUMATICA_CANARY_GI MCPAccess Access-gate GI name
ACUMATICA_WRITES_ENABLED unset (off) true enables the write tools
REDACT_PATTERNS Extra field-name patterns to redact
REDACT_SKIP Field-name patterns to exempt from redaction
ADMIN_SECRET Bearer token for /preflight. Without it /preflight returns 503
MCP_ACCESS_TOKEN_TTL_SECONDS 3600 MCP access-token lifetime; refresh tokens rotate

Operational notes

Run a single instance, or add sticky routing

MCP over streamable HTTP is a session protocol: after initialize, the client's follow-up requests carry an mcp-session-id that must reach the same process. Cloudflare gets this for free because a Durable Object has a stable address; here sessions live in the instance's memory.

On a single instance — the default — this is automatic. If you scale out, enable ARR affinity so a client sticks to one instance:

az webapp update --name mcp4acumatica --resource-group mcp4acumatica-rg --client-affinity-enabled true

Without it, a request routed elsewhere gets a 404 Session not found and the client silently re-initializes — recoverable, but it drops in-flight context.

Note that token refresh is safe to scale out regardless: it is serialized by a Table Storage lease, not by process memory. That matters because Acumatica rotates the refresh token on every use, so two concurrent refreshes would otherwise race and one would be wrongly treated as permanently dead.

Expired rows are swept, not auto-expired

Table Storage has no native TTL. Rows carry an expiresAt column; reads treat an expired row as absent, and a sweeper runs every 15 minutes to reclaim the rest. Without the sweeper the per-minute rate-limit buckets would accumulate forever.

Audit logs

Tool invocations, redaction events, auth events, and write mutations are written as NDJSON to the logs container under do-logs/{date}/, batched (25 entries or 15 seconds) to keep the blob count manageable. The batch is also flushed on SIGTERM, so a deploy does not lose the tail of the trail.

Everything is additionally written to stdout, which App Service captures — use az webapp log tail for live debugging and the blobs for the durable record.

Cost

At rest this is the App Service plan plus a few cents of storage. B1 is around $13/month at the time of writing; Table and Blob usage for a small team is negligible. Compare with Cloudflare, where Logpush requires the $5/month Workers Paid plan.

Private networking

If Acumatica is reachable only inside a VNet, enable VNet integration on the web app and ensure DNS resolves the instance hostname from within it:

az webapp vnet-integration add --name mcp4acumatica --resource-group mcp4acumatica-rg --vnet <vnet-name> --subnet <subnet-name>

The server needs outbound access to the Acumatica instance, to Azure Storage, and — for CIMD clients such as Claude.ai — to the public internet to fetch the client's metadata document. That last fetch is deliberately restricted to public HTTPS addresses; the guard in src/oauth/clients.ts refuses anything resolving to a private or link-local address, which notably blocks the instance metadata service at 169.254.169.254.


Not yet ported


Troubleshooting

Symptom Cause
Startup fails: COOKIE_ENCRYPTION_KEY must be 64 hex characters The key is not 32 bytes of hex. Regenerate with openssl rand -hex 32
Startup logs using an IN-MEMORY store Neither storage setting is configured
AuthorizationPermissionMismatch Managed-identity role assignments have not propagated yet, or were never made
Login ends on "Access Denied" The user cannot read the canary GI. This is the gate working — grant access in Acumatica
Login ends on "Configuration Error" The canary GI is missing, not OData-exposed, or ACUMATICA_TENANT is wrong. Run /preflight
Token exchange error page Connected Application misconfigured — wrong secret, or the redirect URI does not match exactly
Claude reconnects repeatedly Check for token_resolve_outcome entries in the logs; reason: decrypt_failed means COOKIE_ENCRYPTION_KEY changed
404 Session not found The instance restarted, the session was idle >30 min, or you scaled out without ARR affinity
Schema tools missing from the tool list Expected when no schema index is uploaded. Build it with npm run build-schema-index and upload schema-index.json to the index container

Relationship to the Cloudflare deployment

Both hosts can run against the same Acumatica instance simultaneously — they share nothing but Acumatica itself. Each needs its own callback URL registered on the Connected Application, and each maintains its own token store, so a user connecting to both authorizes twice.

For the platform-agnostic design this port is built on — IKeyValueStore, IBlobStore, ITokenProvider, and AppEnv — see the self-hosting guide and architecture.