MCP server
Every app has an MCP server that gives AI assistants the same tools as the API. Connect any MCP client, sign in with OAuth or use the secret key, and choose how much each connection can change.
The server runs in the Offer API, next to the routes it wraps. Each tool is one API endpoint, called with the app's own secret key, so a tool returns exactly what the endpoint returns. There is nothing extra to deploy and no new environment variable.
Server URL
Each app has its own server, scoped to that app, at /apps/:appId/mcp on the API's public URL:
https://api.example.com/apps/app_AbCdEf/mcpLocally that's http://localhost:6767/apps/<appId>/mcp. The dashboard shows the full URL with a copy button under Developers → MCP server. Tools never take an appId argument: the app comes from the URL.
- Transport
- Streamable HTTP, stateless. Every
POSTcarries one JSON-RPC message, or a batch for older clients, and gets a plain JSON response. There are no server-sent event streams, soGETandDELETEreturn405. Notifications get an empty202. - Protocol versions
2025-11-25,2025-06-18,2025-03-26and2024-11-05. The server answersinitializewith the version the client asked for, or the newest one.- Capabilities
- Tools, resources (with one template) and prompts. The lists don't change during a session.
localhost:6767 works with clients that run on your machine, such as Cursor, VS Code and terminal coding agents.Connect a client
The Connect tab on the dashboard's MCP page has these steps for each client, filled in with the app's server URL. Snippets that use a key read it from OFFER_SECRET_KEY rather than holding it.
Cursor
Paste the config into .cursor/mcp.json for one project or ~/.cursor/mcp.json for all of them, or use Add to Cursor on the Connect tab. Without the headers entry, Cursor opens the dashboard's sign-in page the first time a tool runs. Check Settings → MCP: Offer should show a green dot and its tools.
{
"mcpServers": {
"offer": {
"url": "https://api.example.com/apps/app_AbCdEf/mcp",
"headers": { "Authorization": "Bearer ${env:OFFER_SECRET_KEY}" }
}
}
}VS Code
Add the config to .vscode/mcp.json. VS Code asks for the key the first time and stores it securely. For OAuth, drop inputs and headers, then click Start above the server entry and sign in. In Copilot Chat, switch to Agent mode and pick Offer's tools.
{
"inputs": [
{ "type": "promptString", "id": "offer-key", "description": "Offer secret key", "password": true }
],
"servers": {
"offer": {
"type": "http",
"url": "https://api.example.com/apps/app_AbCdEf/mcp",
"headers": { "Authorization": "Bearer ${input:offer-key}" }
}
}
}ChatGPT
- Open Settings → Apps & Connectors → Advanced and turn on Developer mode.
- Click Create, name it
Offer, paste the server URL and choose OAuth. - Sign in to the dashboard, then enable the connector from a chat's tools menu.
Other clients
Any client that speaks Streamable HTTP can use the server URL, with the secret key as a bearer token or with OAuth, which clients discover from the 401. Clients that only speak stdio can bridge with mcp-remote, which also handles the OAuth sign-in. Add "--header", "Authorization: Bearer ${OFFER_SECRET_KEY}" to its args to use the key.
{
"mcpServers": {
"offer": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://api.example.com/apps/app_AbCdEf/mcp"]
}
}
}Authentication
The server takes two kinds of credential. Anything else, including the publishable key and account tokens, gets a 401.
| Credential | Who uses it | What it can call |
|---|---|---|
| OAuth access token | ChatGPT, Cursor, VS Code and any client that signs in. One connection per person and client. | The tools its access level covers, among those switched on for the app. |
| Secret key | Your own tools and scripts. The admin key works too. | Every tool, like the REST API. The access level and tool switches don't apply. |
Both stop working when the server is turned off: every call then gets a 403.
Signing in with OAuth
The API is both the MCP server and its OAuth 2.1 authorization server. People approve connections on the dashboard, signed in with their usual account. Clients find everything on their own:
The client calls without a token
The server answers
401and points to its metadata:HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/apps/app_AbCdEf/mcp"The protected resource metadata names the API as the authorization server, and
/.well-known/oauth-authorization-serverlists its endpoints.The client registers
Dynamic client registration at
POST /oauth/register. Redirect URIs must be HTTPS, HTTP on a loopback host (any port), or an app scheme such ascursor://.The person approves on the dashboard
The client opens
/oauth/authorizewith a PKCE challenge (S256 only). The API saves the request and sends the browser to the dashboard's consent page,/oauth/consent. After signing in, the person picks an access level, and an app if the client didn't name the server's URL inresource. Only apps in their own workspaces are offered. A request expires after 15 minutes.The client gets tokens
The browser returns to the client with a code, valid once for 5 minutes. The client exchanges it at
POST /oauth/tokenfor an access token (mcp_at_…, 1 hour) and a refresh token (mcp_rt_…, 30 days). Each refresh replaces both tokens, and the old pair stops working.
A token only works on the app it was approved for. The API stores tokens as SHA-256 hashes. When the same person approves the same registered client again, its existing connection takes the new access level. The scopes are mcp:read, mcp:write and mcp:full. A client that asks for one has it preselected on the consent page, and the person can still choose another.
Using the secret key
Send Authorization: Bearer key_… on every request. At initialize, the server records a connection named after the client (clientInfo.name) and returns an Mcp-Session-Id header. Clients send it back, and later calls are logged under that connection.
curl -X POST http://localhost:6767/apps/$APP_ID/mcp \
-H "Authorization: Bearer $OFFER_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "get_account_access", "arguments": { "namespaceId": "user_42" } }
}'{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "{\"id\":\"pro\",\"name\":\"Pro\",\"entitlements\":[…]}" }],
"isError": false
}
}The result is the endpoint's JSON as text. When the endpoint returns an error, the result has isError: true and text such as Error 404: Namespace not found, so the model can read it and recover.
Access levels
Every tool is one of three kinds, and each access level covers more of them:
| Level | Scope | Tools it covers |
|---|---|---|
| Read only | mcp:read | Look up accounts, the catalog, usage, analytics and webhooks. Nothing changes. |
| Read & write | mcp:write | Also create and update accounts, plans, entitlements, add-ons, incentives and webhooks, attach entitlements and add-ons, record usage, rename the app, send test events and retry deliveries. No deletes. |
| Full access | mcp:full | Everything, including deletes, detaching entitlements and add-ons, and removing an account's incentive. |
An OAuth connection can call a tool when both of these hold:
- The tool is switched on for the app. The app's access level decides which tools are on by default (Read & write for a new app), and per-tool switches override it. This applies to every OAuth connection to the app.
- The connection's own level covers the tool. That's the level the person picked when they approved it.
tools/list only returns the tools a connection can call. Calling another one returns isError with the reason, for example that the connection has Read only access and the tool needs Read & write. Denied calls show up in Activity with status 403.
readOnlyHint, deletes are destructiveHint, and the two that call your webhook URLs are openWorldHint. Most clients ask before running a destructive tool.Tools
64 tools, each wrapping one endpoint. Arguments are the endpoint's path parameters, query parameters and body fields, with the same names. A few filters are left out, such as offer on list_accounts. Routes below are relative to /apps/:appId, and the last column is the lowest access level that can call each tool.
Tools take the account id as namespaceId, so routes show it as :namespaceId. The Endpoint reference writes the same parameter as :accountId.
Accounts
| Tool | Endpoint | Needs |
|---|---|---|
| list_accounts | GET /namespaces | Read only |
| count_accounts | GET /namespaces/count | Read only |
| list_accounts_with_incentive | GET /namespaces/with-incentive | Read only |
| create_account | POST /namespaces | Read & write |
| get_account | GET /namespaces/:namespaceId | Read only |
| update_account | PATCH /namespaces/:namespaceId | Read & write |
| delete_account | DELETE /namespaces/:namespaceId | Full access |
| remove_account_incentive | DELETE /namespaces/:namespaceId/incentive | Full access |
| get_account_access | GET /namespaces/:namespaceId/plan | Read only |
| get_account_access_private | GET /namespaces/:namespaceId/full-plan | Read only |
Usage
| Tool | Endpoint | Needs |
|---|---|---|
| get_account_usage | GET /namespaces/:namespaceId/usage | Read only |
| get_usage_count | GET /namespaces/:namespaceId/usage/:entitlementId | Read only |
| increment_usage | POST /namespaces/:namespaceId/usage/:entitlementId/add | Read & write |
| decrement_usage | POST /namespaces/:namespaceId/usage/:entitlementId/remove | Read & write |
| add_usage_amount | POST /namespaces/:namespaceId/usage/:entitlementId/amount | Read & write |
Plans and pricing
| Tool | Endpoint | Needs |
|---|---|---|
| list_plans | GET /plans | Read only |
| create_plan | POST /plans | Read & write |
| get_plan | GET /plans/:planId | Read only |
| update_plan | PATCH /plans/:planId | Read & write |
| delete_plan | DELETE /plans/:planId | Full access |
| merge_plan_meta | PATCH /plans/:planId/meta | Read & write |
| list_plan_accounts | GET /plans/:planId/namespaces | Read only |
| attach_plan_entitlement | POST /plans/:planId/entitlements | Read & write |
| set_plan_entitlement_limit | PATCH /plans/:planId/entitlements/:entitlementId | Read & write |
| detach_plan_entitlement | DELETE /plans/:planId/entitlements/:entitlementId | Full access |
| attach_plan_addon | POST /plans/:planId/addons | Read & write |
| detach_plan_addon | DELETE /plans/:planId/addons/:addonId | Full access |
| list_pricing_cards | GET /plans/pricing | Read only |
| get_pricing_card | GET /plans/:planId/pricing | Read only |
Entitlements
| Tool | Endpoint | Needs |
|---|---|---|
| list_entitlements | GET /entitlements | Read only |
| create_entitlement | POST /entitlements | Read & write |
| get_entitlement | GET /entitlements/:entitlementId | Read only |
| update_entitlement | PATCH /entitlements/:entitlementId | Read & write |
| delete_entitlement | DELETE /entitlements/:entitlementId | Full access |
Add-ons
| Tool | Endpoint | Needs |
|---|---|---|
| list_addons | GET /addons | Read only |
| create_addon | POST /addons | Read & write |
| get_addon | GET /addons/:addonId | Read only |
| update_addon | PATCH /addons/:addonId | Read & write |
| delete_addon | DELETE /addons/:addonId | Full access |
Incentives
| Tool | Endpoint | Needs |
|---|---|---|
| list_incentives | GET /incentives | Read only |
| create_incentive | POST /incentives | Read & write |
| get_incentive | GET /incentives/:incentiveId | Read only |
| update_incentive | PATCH /incentives/:incentiveId | Read & write |
| delete_incentive | DELETE /incentives/:incentiveId | Full access |
| attach_incentive_entitlement | POST /incentives/:incentiveId/entitlements | Read & write |
| set_incentive_entitlement_limit | PATCH /incentives/:incentiveId/entitlements/:entitlementId | Read & write |
| detach_incentive_entitlement | DELETE /incentives/:incentiveId/entitlements/:entitlementId | Full access |
| attach_incentive_addon | POST /incentives/:incentiveId/addons | Read & write |
| detach_incentive_addon | DELETE /incentives/:incentiveId/addons/:addonId | Full access |
Analytics
| Tool | Endpoint | Needs |
|---|---|---|
| get_usage_summary | GET /analytics | Read only |
| top_accounts_by_usage | GET /analytics/top-namespaces | Read only |
| get_usage_timeseries | GET /analytics/timeseries | Read only |
| list_usage_events | GET /analytics/events | Read only |
Webhooks and events
| Tool | Endpoint | Needs |
|---|---|---|
| list_webhooks | GET /webhooks | Read only |
| create_webhook | POST /webhooks | Read & write |
| get_webhook | GET /webhooks/:webhookId | Read only |
| update_webhook | PATCH /webhooks/:webhookId | Read & write |
| delete_webhook | DELETE /webhooks/:webhookId | Full access |
| send_test_event | POST /webhooks/:webhookId/test | Read & write |
| list_webhook_deliveries | GET /webhooks/:webhookId/deliveries | Read only |
| retry_webhook_delivery | POST /webhooks/:webhookId/deliveries/:deliveryId/retry | Read & write |
| list_events | GET /events | Read only |
App
| Tool | Endpoint | Needs |
|---|---|---|
| get_app | GET / | Read only |
| update_app | PATCH / | Read & write |
A few tools differ from the endpoint they wrap:
get_appandupdate_appreturnapi_keyandpublic_keyas[redacted], the webhook tools redact signing secrets, andupdate_apponly changesname.merge_plan_metatakes the keys to merge under ametaargument instead of as the raw body.
Not available over MCP
These endpoints have no tool, because a mistake would lock you out:
| Endpoint | Why |
|---|---|
| DELETE / | Deleting the app removes every record. Do it from App settings. |
| POST /keys/regenerate | Rotating the secret key would cut off your servers. Rotate it from API keys. |
| POST /public-key/regenerate | Rotating the publishable key would cut off your clients. Rotate it from API keys. |
| POST /webhooks/:webhookId/secret/regenerate | Verification breaks until your server has the new secret. Rotate it from the webhook's page. |
Tools cover accounts, usage, the catalog, analytics, webhooks and the app. Offers, checkouts, subscriptions, PayPal, cancel flows and cancel sessions, saved reports, account tokens, account add-ons and history import aren't exposed over MCP. Use the REST API for those.
Resources
Read-only JSON documents a client can load as context. Keys are always redacted.
| URI | What it holds |
|---|---|
| offer://app | The app's name and id. |
| offer://catalog | Every plan, entitlement, add-on and incentive in one document. Clients usually load this first. |
| offer://pricing | The pricing cards your pricing page renders. |
| offer://accounts/{namespaceId} | A template: one account's resolved plan, incentive, add-ons and live usage. |
| offer://events | Events from the last 30 days, newest first. |
Resources follow the tools that read the same data. An OAuth connection can only read offer://catalog while list_plans, list_entitlements, list_addons and list_incentives are all on, and likewise for the others. The secret key can read all of them.
Prompts
Prompts are starting points the client offers as commands. Each expands to one message that names the tools to use.
| Prompt | Arguments | What it does |
|---|---|---|
| review_catalog | None | Audits plans, entitlements and incentives for unused records, dangling references and inconsistent limits, and suggests fixes without applying them. |
| investigate_account | namespaceId | Explains what an account can do right now and why: plan, incentive overrides, add-ons and usage against each limit. |
| launch_incentive | goal, plan (optional) | Drafts an incentive, creates it after you approve, and lists the accounts it should go to before assigning it. |
| debug_webhooks | webhookId (optional) | Groups failed deliveries by cause and suggests a fix for each. Asks before retrying or sending a test. |
The server's instructions also tell clients to read the catalog before acting and to confirm with you before creating, changing or deleting anything.
Dashboard controls
Developers → MCP server (/apps/[appId]/developers/mcp) shows the server URL and an Enabled switch, with four tabs:
- Enabled
- On by default. Turning it off answers every call with 403, for OAuth connections and the secret key alike, and stops new sign-ins. Connections are kept, so turning it back on restores them.
- Connect
- Setup steps and snippets for each client, with this app's URL filled in.
- Tools
- The app's access level and a switch per tool. A switch you change by hand overrides the level until you reset it. These limit OAuth connections only.
- Connections
- Every client connected to the app: OAuth connections with the person who approved them and their access level, and secret-key connections by client name. Removing an OAuth connection cuts it off on its next call, and its refresh token stops working.
- Activity
- Every tool call with its arguments, status, duration and the result the client got. Results over 20,000 characters are truncated. Calls are kept for 30 days.
Endpoints
The server and the OAuth routes clients call. Paths are relative to the API's base URL. Every route is also in the Endpoint reference and the Postman collection, whose MCP OAuth folder runs the whole sign-in flow.
- POST
/apps/:appId/mcpThe app's MCP server: one JSON-RPC message (or a batch) per request, answered with JSON. 401 points to the resource metadata; 403 when the server is turned off. - GET
/apps/:appId/mcp405. The server is stateless and never streams to the client. - DELETE
/apps/:appId/mcp405. There is no session to end. - GET
/.well-known/oauth-protected-resource/apps/:appId/mcpProtected resource metadata (RFC 9728): the server URL, its authorization server and scopes. - GET
/.well-known/oauth-protected-resourceThe same, for clients that drop the path. The resource is the API's origin. - GET
/.well-known/oauth-authorization-serverAuthorization server metadata (RFC 8414). Also served under /.well-known/oauth-authorization-server/* and /.well-known/openid-configuration. - POST
/oauth/registerDynamic client registration (RFC 7591). Body: { redirect_uris, client_name?, token_endpoint_auth_method?, client_uri?, logo_uri? }. - GET
/oauth/authorizeStarts an authorization code request with PKCE (S256) and redirects to the dashboard's consent page. - POST
/oauth/tokengrant_type authorization_code (with code_verifier) or refresh_token. Refresh tokens rotate on every use. - POST
/oauth/revokeRevokes an access or refresh token (RFC 7009), which ends the connection.
The dashboard uses these admin-only routes for the MCP page and the consent page:
- GET
/apps/:appId/mcp/settingsThe server's settings: enabled, access_level and tool_overrides. - PATCH
/apps/:appId/mcp/settingsUpdates enabled, access_level (read, write or full) or tool_overrides, a map of tool name to true or false that replaces the old one. - GET
/apps/:appId/mcp/connectionsConnected clients, most recently used first, with tool calls in the last 7 days. - DELETE
/apps/:appId/mcp/connections/:connectionIdRemoves a connection. An OAuth connection loses access on its next call. - GET
/apps/:appId/mcp/callsTool calls, newest first, kept 30 days. Query: connection_id, limit. - GET
/oauth/requests/:requestIdA pending authorization request, for the consent page. 404 once used or expired. - POST
/oauth/requests/:requestId/approveApproves a request. Body: { user: { id, name?, email? }, access_level, app_id? }. Returns redirect_url with the code. - POST
/oauth/requests/:requestId/denyDenies a request. Returns redirect_url with error=access_denied.
Self-hosting
The server needs no setup beyond the API itself. Its tables come from migrations/009_mcp.sql, which runs on boot, and it keeps no state in memory, so it works across several API instances. Two existing variables matter for OAuth:
| Variable | Service | Role in MCP |
|---|---|---|
| PUBLIC_API_URL | API | The origin used in the OAuth metadata and the server's resource URL. Falls back to RENDER_EXTERNAL_URL, then to the request's X-Forwarded-Host or Host. |
| APP_URL | API | The dashboard, where the consent page lives (/oauth/consent). |
onrender.com address, so a server URL on your own domain fails to sign in until PUBLIC_API_URL is set to that domain. Give clients URLs on the same origin.The code is in api/src/mcp (protocol, tools, connections, resources and prompts), api/src/routes/mcp.ts and api/src/routes/oauth.ts. The tool list, api/src/mcp/tools.json, is generated from the dashboard's API reference with pnpm mcp:tools in offer-app, and a dashboard test fails when it's stale. bun test test/mcp.test.ts in api/ runs the protocol, the settings and the full OAuth flow end to end. All variables are listed in Environment variables.