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/mcp

Locally 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 POST carries one JSON-RPC message, or a batch for older clients, and gets a plain JSON response. There are no server-sent event streams, so GET and DELETE return 405. Notifications get an empty 202.
Protocol versions
2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. The server answers initialize with 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.
Web chat apps connect from their own servers. Their connectors, such as ChatGPT's, need the API on a public HTTPS URL. A local API at 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.

mcp.json
{
  "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.

.vscode/mcp.json
{
  "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

  1. Open Settings → Apps & Connectors → Advanced and turn on Developer mode.
  2. Click Create, name it Offer, paste the server URL and choose OAuth.
  3. 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.

CredentialWho uses itWhat it can call
OAuth access tokenChatGPT, 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 keyYour 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:

  1. The client calls without a token

    The server answers 401 and 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-server lists its endpoints.

  2. 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 as cursor://.

  3. The person approves on the dashboard

    The client opens /oauth/authorize with 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 in resource. Only apps in their own workspaces are offered. A request expires after 15 minutes.

  4. 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/token for 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.

A tool call with curl
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" } }
  }'
Response
{
  "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.

Removing a key connection doesn't cut it off. It only leaves the list, because the key itself still works. To stop a client that uses the secret key, regenerate the key under Developers → API keys, which also breaks every server using it. Prefer OAuth for assistants that people connect themselves.

Access levels

Every tool is one of three kinds, and each access level covers more of them:

LevelScopeTools it covers
Read onlymcp:readLook up accounts, the catalog, usage, analytics and webhooks. Nothing changes.
Read & writemcp:writeAlso 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 accessmcp:fullEverything, 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.

Clients confirm before destructive tools. Tools carry MCP annotations: reads are 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

ToolEndpointNeeds
list_accountsGET /namespacesRead only
count_accountsGET /namespaces/countRead only
list_accounts_with_incentiveGET /namespaces/with-incentiveRead only
create_accountPOST /namespacesRead & write
get_accountGET /namespaces/:namespaceIdRead only
update_accountPATCH /namespaces/:namespaceIdRead & write
delete_accountDELETE /namespaces/:namespaceIdFull access
remove_account_incentiveDELETE /namespaces/:namespaceId/incentiveFull access
get_account_accessGET /namespaces/:namespaceId/planRead only
get_account_access_privateGET /namespaces/:namespaceId/full-planRead only

Usage

ToolEndpointNeeds
get_account_usageGET /namespaces/:namespaceId/usageRead only
get_usage_countGET /namespaces/:namespaceId/usage/:entitlementIdRead only
increment_usagePOST /namespaces/:namespaceId/usage/:entitlementId/addRead & write
decrement_usagePOST /namespaces/:namespaceId/usage/:entitlementId/removeRead & write
add_usage_amountPOST /namespaces/:namespaceId/usage/:entitlementId/amountRead & write

Plans and pricing

ToolEndpointNeeds
list_plansGET /plansRead only
create_planPOST /plansRead & write
get_planGET /plans/:planIdRead only
update_planPATCH /plans/:planIdRead & write
delete_planDELETE /plans/:planIdFull access
merge_plan_metaPATCH /plans/:planId/metaRead & write
list_plan_accountsGET /plans/:planId/namespacesRead only
attach_plan_entitlementPOST /plans/:planId/entitlementsRead & write
set_plan_entitlement_limitPATCH /plans/:planId/entitlements/:entitlementIdRead & write
detach_plan_entitlementDELETE /plans/:planId/entitlements/:entitlementIdFull access
attach_plan_addonPOST /plans/:planId/addonsRead & write
detach_plan_addonDELETE /plans/:planId/addons/:addonIdFull access
list_pricing_cardsGET /plans/pricingRead only
get_pricing_cardGET /plans/:planId/pricingRead only

Entitlements

ToolEndpointNeeds
list_entitlementsGET /entitlementsRead only
create_entitlementPOST /entitlementsRead & write
get_entitlementGET /entitlements/:entitlementIdRead only
update_entitlementPATCH /entitlements/:entitlementIdRead & write
delete_entitlementDELETE /entitlements/:entitlementIdFull access

Add-ons

ToolEndpointNeeds
list_addonsGET /addonsRead only
create_addonPOST /addonsRead & write
get_addonGET /addons/:addonIdRead only
update_addonPATCH /addons/:addonIdRead & write
delete_addonDELETE /addons/:addonIdFull access

Incentives

ToolEndpointNeeds
list_incentivesGET /incentivesRead only
create_incentivePOST /incentivesRead & write
get_incentiveGET /incentives/:incentiveIdRead only
update_incentivePATCH /incentives/:incentiveIdRead & write
delete_incentiveDELETE /incentives/:incentiveIdFull access
attach_incentive_entitlementPOST /incentives/:incentiveId/entitlementsRead & write
set_incentive_entitlement_limitPATCH /incentives/:incentiveId/entitlements/:entitlementIdRead & write
detach_incentive_entitlementDELETE /incentives/:incentiveId/entitlements/:entitlementIdFull access
attach_incentive_addonPOST /incentives/:incentiveId/addonsRead & write
detach_incentive_addonDELETE /incentives/:incentiveId/addons/:addonIdFull access

Analytics

ToolEndpointNeeds
get_usage_summaryGET /analyticsRead only
top_accounts_by_usageGET /analytics/top-namespacesRead only
get_usage_timeseriesGET /analytics/timeseriesRead only
list_usage_eventsGET /analytics/eventsRead only

Webhooks and events

ToolEndpointNeeds
list_webhooksGET /webhooksRead only
create_webhookPOST /webhooksRead & write
get_webhookGET /webhooks/:webhookIdRead only
update_webhookPATCH /webhooks/:webhookIdRead & write
delete_webhookDELETE /webhooks/:webhookIdFull access
send_test_eventPOST /webhooks/:webhookId/testRead & write
list_webhook_deliveriesGET /webhooks/:webhookId/deliveriesRead only
retry_webhook_deliveryPOST /webhooks/:webhookId/deliveries/:deliveryId/retryRead & write
list_eventsGET /eventsRead only

App

ToolEndpointNeeds
get_appGET /Read only
update_appPATCH /Read & write

A few tools differ from the endpoint they wrap:

  • get_app and update_app return api_key and public_key as [redacted], the webhook tools redact signing secrets, and update_app only changes name.
  • merge_plan_meta takes the keys to merge under a meta argument instead of as the raw body.

Not available over MCP

These endpoints have no tool, because a mistake would lock you out:

EndpointWhy
DELETE /Deleting the app removes every record. Do it from App settings.
POST /keys/regenerateRotating the secret key would cut off your servers. Rotate it from API keys.
POST /public-key/regenerateRotating the publishable key would cut off your clients. Rotate it from API keys.
POST /webhooks/:webhookId/secret/regenerateVerification 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.

URIWhat it holds
offer://appThe app's name and id.
offer://catalogEvery plan, entitlement, add-on and incentive in one document. Clients usually load this first.
offer://pricingThe pricing cards your pricing page renders.
offer://accounts/{namespaceId}A template: one account's resolved plan, incentive, add-ons and live usage.
offer://eventsEvents 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.

PromptArgumentsWhat it does
review_catalogNoneAudits plans, entitlements and incentives for unused records, dangling references and inconsistent limits, and suggests fixes without applying them.
investigate_accountnamespaceIdExplains what an account can do right now and why: plan, incentive overrides, add-ons and usage against each limit.
launch_incentivegoal, plan (optional)Drafts an incentive, creates it after you approve, and lists the accounts it should go to before assigning it.
debug_webhookswebhookId (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/mcp
    The 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.Secret key, or an OAuth access token for this app
  • GET/apps/:appId/mcp
    405. The server is stateless and never streams to the client.No credential
  • DELETE/apps/:appId/mcp
    405. There is no session to end.No credential
  • GET/.well-known/oauth-protected-resource/apps/:appId/mcp
    Protected resource metadata (RFC 9728): the server URL, its authorization server and scopes.No credential
  • GET/.well-known/oauth-protected-resource
    The same, for clients that drop the path. The resource is the API's origin.No credential
  • GET/.well-known/oauth-authorization-server
    Authorization server metadata (RFC 8414). Also served under /.well-known/oauth-authorization-server/* and /.well-known/openid-configuration.No credential
  • POST/oauth/register
    Dynamic client registration (RFC 7591). Body: { redirect_uris, client_name?, token_endpoint_auth_method?, client_uri?, logo_uri? }.No credential
  • GET/oauth/authorize
    Starts an authorization code request with PKCE (S256) and redirects to the dashboard's consent page.No credential
  • POST/oauth/token
    grant_type authorization_code (with code_verifier) or refresh_token. Refresh tokens rotate on every use.Client id, plus its secret for confidential clients
  • POST/oauth/revoke
    Revokes an access or refresh token (RFC 7009), which ends the connection.Client id, plus its secret for confidential clients

The dashboard uses these admin-only routes for the MCP page and the consent page:

  • GET/apps/:appId/mcp/settings
    The server's settings: enabled, access_level and tool_overrides.Admin key only
  • PATCH/apps/:appId/mcp/settings
    Updates enabled, access_level (read, write or full) or tool_overrides, a map of tool name to true or false that replaces the old one.Admin key only
  • GET/apps/:appId/mcp/connections
    Connected clients, most recently used first, with tool calls in the last 7 days.Admin key only
  • DELETE/apps/:appId/mcp/connections/:connectionId
    Removes a connection. An OAuth connection loses access on its next call.Admin key only
  • GET/apps/:appId/mcp/calls
    Tool calls, newest first, kept 30 days. Query: connection_id, limit.Admin key only
  • GET/oauth/requests/:requestId
    A pending authorization request, for the consent page. 404 once used or expired.Admin key only
  • POST/oauth/requests/:requestId/approve
    Approves a request. Body: { user: { id, name?, email? }, access_level, app_id? }. Returns redirect_url with the code.Admin key only
  • POST/oauth/requests/:requestId/deny
    Denies a request. Returns redirect_url with error=access_denied.Admin key only

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:

VariableServiceRole in MCP
PUBLIC_API_URLAPIThe 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_URLAPIThe dashboard, where the consent page lives (/oauth/consent).
Behind a custom domain, set PUBLIC_API_URL. OAuth clients check that the metadata matches the URL they connected to. On Render the origin defaults to the service's 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.