Skip to main content

Customer API and MCP usage limits

Each user may make 500 customer API requests and 500 MCP tool calls per UTC day in prod. The limits stop abuse and runaway integrations or agents; they are not a billing meter. Tracked in issue 689.

The limits​

  • Customer API (/v3/customer on the Flask api service): CUSTOMER_API_DAILY_LIMIT, 500 in prod, 5000 on staging and test, 100000 in local docker-compose. Usage history (each user's daily request count in api_usage) is kept for 90 days, then removed automatically.
  • MCP server (mcp-server): MCP_DAILY_LIMIT, with the same values per environment. Usage history (each user's daily tool call count in mcp_usage) is kept for 90 days, then removed automatically.

The limit is per user (the owner of the API key or MCP key), not per client account. Every user is a paid seat, so a customer cannot multiply the limit for free. Days are fixed UTC days: every count resets at 00:00 UTC, whatever the customer's time zone.

Staging and test carry 5000 so repeated E2E runs by the shared test users never reach the limit.

What counts​

  • Customer API: every request made with a valid key, including a 400 or 404. A 401 (bad key) or 403 (no active subscription) is not counted, because the counter runs after those checks. Refused 429 requests keep counting, so a count above the limit shows how hard a client kept trying after it was refused.
  • MCP server: only tools/call. initialize, tools/list, ping, the session stream and the session delete never count, so a client over the limit can still connect and receive the explanation. A call to an unknown tool is not counted.

What the user sees​

  • Customer API: HTTP 429 with a message that states the limit and the reset time, plus limit, reset_at and retry_after_seconds in the body. Every keyed response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After. The public page is Errors and limits.
  • MCP server: an isError tool result whose text states the limit and the reset time and tells the assistant not to retry. It is not an HTTP 429, because MCP clients surface a 429 as a connection error and some retry it. whoami reports daily_limit, used_today and resets_at.
  • Settings page: both key cards show "Used today: N of 500 ... Resets at midnight UTC." once a key exists.

Where the counters live​

Three Mongo collections in nexgen_mdb, each holding one document per user per UTC day with a count:

  • api_usage: customer API requests, written by the Flask api. Fields _id ("nexgen:<user_id>:<day>"), app, user_id, day, count, created_at.
  • mcp_usage: MCP tool calls, written by the mcp-server. Same fields.
  • ai_usage: AI calls only (the label ingredient translation cap). Its documents also carry a feature. Usage history is kept for 90 days, then removed automatically, like the other two. Documents written before October 2026 have no created_at, so they are never removed automatically; that includes the customer API's old counts here under feature: "customer_api", which were left in place.

app is always nexgen: both services only accept keys from the paid database. Each write is one atomic findOneAndUpdate on the _id with $inc and upsert, no aggregation. Keying on _id is what stops two concurrent first requests of the day from creating two documents: the _id index always exists and is always unique, so it holds from the first request, whether or not the indexes below have been created.

Indexes, all created by nexgen/backend/create_indexes.php:

  • api_usage and mcp_usage: {day} for the admin page, and a TTL index on created_at that removes documents after 90 days (expireAfterSeconds: 7776000), so a day more than 90 days back shows no usage. They need no unique index of their own (see the _id above).
  • ai_usage: non-unique {app, user_id, feature, day}, and the same 90-day TTL index on created_at. The first is not unique because older documents may already hold duplicates, and merging them would mean deleting some.

Read a user's usage​

The quickest view is the admin API Usage page at /admin/usage (Axxya staff only): every user with customer API requests or MCP tool calls on a chosen UTC day, highest first, 100 rows to a page, with their account, the count, the current limit, and how many requests were refused. Rows over the limit are highlighted. The User box finds one user by their exact email address or user id. Earlier days are compared with today's limits. Counts are kept for 90 days.

From the bastion, read-only (see Nexgen V3 databases for the connection):

db.api_usage.find({ _id: 'nexgen:1234:2026-10-25' })
db.mcp_usage.find({ app: 'nexgen', user_id: 1234 }).sort({ day: -1 }).limit(7)
db.api_usage.find({ day: '2026-10-25' }).sort({ count: -1 }).limit(20)

Replace 1234 with the user's id from the MySQL users table, and the day with the UTC day you need.

Change a limit​

The limits are environment variables, so a change is a manifest edit and a deploy, with no code change. Each variable is set in two manifests that must agree:

  • CUSTOMER_API_DAILY_LIMIT in copilot/api/manifest.yml (enforces it) and copilot/nexgen-backend/manifest.yml (shows it on the Settings card and the admin page).
  • MCP_DAILY_LIMIT in copilot/mcp-server/manifest.yml (enforces it) and copilot/nexgen-backend/manifest.yml.

The prod value is the top-level variables entry; staging and test override it in their environments blocks. Merge to main for staging, and deploy prod by hand (./deploy.sh prod api nexgen-backend or ./deploy.sh prod mcp-server nexgen-backend). There is no per-user override today; a customer who needs more is a code change.

The MCP server's Mongo grant​

The mcp-server connects to DocumentDB as mcp_readonly, which has the built-in read role. To write its counter it also needs the mcpUsageCounter role, which may find, insert and update the mcp_usage collection and nothing else. Create and grant it once per environment from the bastion:

copilot svc exec --app nexgen-v3 --env staging --name bastion
mongosh "mongodb://$MDB_USERNAME:$MDB_PASSWORD@$MDB_HOST:27017/?authSource=$MDB_AUTH_DB&tls=false" --eval "
db.getSiblingDB('admin').createRole({ role: 'mcpUsageCounter', privileges: [ { resource: { db: 'nexgen_mdb', collection: 'mcp_usage' }, actions: ['find', 'insert', 'update'] } ], roles: [] });
db.getSiblingDB('admin').grantRolesToUser('mcp_readonly', [ { role: 'mcpUsageCounter', db: 'admin' } ])"

Without the role the MCP server keeps working but does not enforce its limit: each tool call logs [mcp-server] usage counter write failed; the call is allowed but not counted, and whoami reports used_today: 0. Check with:

copilot svc logs --name mcp-server --env staging --since 30m | grep "usage counter write failed"

The Flask api writes api_usage as flask_writer, which already has readWrite on nexgen_mdb, so it needs no new grant. Local Mongo runs without access control and needs nothing.

Create the indexes​

On staging and test, create_indexes.php runs in the nexgen-backend entrypoint every time the container starts, so merging to main creates the indexes there, the TTL index included. On prod it is a hand step. Run it after nexgen-backend is deployed and before the api and mcp-server are, so the TTL and {day} indexes exist before the first counter document:

copilot svc exec --app nexgen-v3 --env prod --name nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php /var/www/html/create_indexes.php\"'"

The script prints a line per index and still exits successfully when one fails, so read its output for api_usage and mcp_usage. To confirm, read-only from the bastion:

db.api_usage.getIndexes()
db.mcp_usage.getIndexes()
db.ai_usage.getIndexes()

api_usage and mcp_usage should each list _id_, day_1 and created_at_1 (with expireAfterSeconds: 7776000); ai_usage should list _id_, app_1_user_id_1_feature_1_day_1 and the same created_at_1. The counters are correct without these indexes, because each write is keyed on _id; without {day} the admin page scans the collection, and without the TTL index old documents stay.

When a customer reports a 429​

  1. Open /admin/usage for the day in question (the 429 message names the UTC reset time, which tells you the day) and enter the user's email in the User box. A count far above the limit usually means an integration that retries in a loop.
  2. Tell the customer the limit (500 requests per user per UTC day), when it resets, and that refused requests also count, so retrying straight away makes no progress.
  3. Point them at the ways to stay within the limit on the public Errors and limits page: pages of 200, view=full, updated_since, and caching ingredients.
  4. If their use is genuine and still needs more, raise it with the team; the limit can only be changed for everyone today.