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
- An Azure subscription with permission to create a resource group, an App Service plan, a web app, and a storage account.
- The Azure CLI (
az), logged in:az login. - Node.js 20 or newer locally, to build the bundle.
- 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
MCPAccesscanary 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) fromacumatica/. See generic inquiries.
- A Connected Application (SM303010) with the Authorization Code flow
enabled. Its redirect URI must be
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
- Admin console (
/docs/adminon Cloudflare) — the settings editor, log viewer, and preflight page./preflightis available here as JSON; runtime config can still be changed by writingconfig:*rows directly to the table, and audit logs can be read from the blob container. The console itself is 1,100 lines of Cloudflare-specific code and was left out of this port. - Logpush — Cloudflare-specific. Azure equivalents are App Service diagnostic settings or Application Insights; the durable NDJSON trail in Blob Storage is independent of either.
/install.sh— the one-line installer bootstraps a Cloudflare deploy, so it is not served here.
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.