Connect an AI agent to ReceiptRoller over MCP

MCP AI agent Claude OAuth PKCE Quickstart

In this quickstart you connect an AI agent to the ReceiptRoller MCP server so it can work with sales, products, bookings and other data from a conversation. The first part connects Claude as a connector. The second part is for developers building their own MCP client: you fetch the discovery documents, register a client dynamically, get a token with the authorization code flow and PKCE, call initialize, tools/list and tools/call with curl, and then automate it with a Node.js script.

What you need first

  • A ReceiptRoller account that belongs to a business account. The tools return that business account's real data; there is no sandbox yet.
  • You do not register an app in the developer portal for MCP. The client registers itself through dynamic client registration.
  • For the second part: curl, openssl and Node.js 20 or later.

Every tool calls the ReceiptRoller API with the connected user's own access token. The API's scope checks and role checks therefore apply unchanged: an operation that needs a scope the user did not approve, or that the user's role does not allow (for example the sales figures of a store they cannot see sales for), is refused.

Connection details

ItemValue
MCP endpointhttps://mcp.receiptroller.io/mcp (the /mcp path is required; a POST to the root returns 405)
TransportHTTP POST, JSON-RPC 2.0, protocol version 2025-03-26
Authorization endpointhttps://receiptroller.io/oauth/authorize
Token endpointhttps://receiptroller.io/api/v1/auth/token
Client registrationPOST https://mcp.receiptroller.io/register

Scopes requested

An MCP client can request these 14 scopes. The consent screen lists the ones the client asked for.

ScopeMain tools
sales.readsales_get_kpis and the other sales_* tools
store.products.read / store.products.writepim_list_products, pim_get_product / pim_create_product, pim_update_product
store.orders.read / store.orders.writeoms_*
store.inventory.read / store.inventory.writewms_*
store.reservations.read, store.memberships.read, store.passes.readreservation_*, membership_list_members, pass_list_holders (read only)
sns.content.read, sns.content.write, sns.audience.read, analytics.readsns_*

For what each scope allows, see OAuth Scopes Reference and Availability.

Part 1: Connect Claude

  1. In Claude (claude.ai or Claude Desktop), add a custom connector in the connector settings.
  2. Enter https://mcp.receiptroller.io/mcp as the URL and save.
  3. When you connect, a browser opens the ReceiptRoller sign-in page. Sign in.
  4. Review the consent screen and choose Approve. Back in Claude, the connector shows as connected.

The consent screen shows the client's name, a line saying it wants to access your ReceiptRoller account and that you choose the business account on each request, and a "Permissions requested" list with one checkbox per scope, grouped by category. Every box starts checked; any you clear is not granted, and approving with all of them cleared counts as a refusal. You do not pick a business account on this screen. Each tool call names it with organizationId.

Try it

AskTools called
"Show this month's KPIs for my whole business account, compared with last month."list_my_organizations then sales_get_kpis
"List the products in our product master whose name or SKU contains 'coffee'."pim_list_products (search)
"List tomorrow's bookings at the Shibuya store."list_my_stores then reservation_list (from / to)

Sales tools need an administrator role in the business account for account-wide figures, or a role that can see sales at the store for store figures. Without it the tool returns an error starting with Upstream 403.

Part 2: Connect your own MCP client

From here you run, one step at a time with curl, what Claude does behind the scenes.

Step 1: Fetch the discovery documents

curl https://mcp.receiptroller.io/.well-known/oauth-protected-resource
{
  "resource": "https://mcp.receiptroller.io/mcp",
  "authorization_servers": ["https://mcp.receiptroller.io"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["sns.content.read", "sns.content.write", "...", "store.passes.read"]
}

The same document is also served at /.well-known/oauth-protected-resource/mcp. Next, fetch the authorization server metadata.

curl https://mcp.receiptroller.io/.well-known/oauth-authorization-server
{
  "issuer": "https://mcp.receiptroller.io",
  "authorization_endpoint": "https://receiptroller.io/oauth/authorize",
  "token_endpoint": "https://receiptroller.io/api/v1/auth/token",
  "registration_endpoint": "https://mcp.receiptroller.io/register",
  "scopes_supported": ["sns.content.read", "...", "store.passes.read"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
  "code_challenge_methods_supported": ["S256", "plain"]
}

The authorization and token endpoints are on receiptroller.io, not on mcp.receiptroller.io.

Step 2: Register a client

curl -X POST https://mcp.receiptroller.io/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My MCP Client",
    "redirect_uris": ["http://localhost:8765/callback"]
  }'

The response is 201:

{
  "client_id": "mcp_xxxxxxxxxxxxxxxxxxxx",
  "client_secret": "{client_secret}",
  "client_id_issued_at": 1793000000,
  "client_secret_expires_at": 0,
  "redirect_uris": ["http://localhost:8765/callback"],
  "client_name": "My MCP Client",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "client_secret_post"
}
  • The client_secret is shown only in this response. Store it.
  • Only the first entry in redirect_uris is stored. Use exactly that value in the authorization request and the token exchange.
  • client_name appears on the consent screen. If you omit it, it is Unnamed MCP client.
  • An empty redirect_uris returns 400 invalid_redirect_uri.

Step 3: Create the PKCE values

code_verifier=$(openssl rand -base64 48 | tr -d '=+/\n' | cut -c1-64)
code_challenge=$(printf '%s' "$code_verifier" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n')
echo "$code_verifier"; echo "$code_challenge"

Step 4: Authorize in the browser

Open this URL in a browser (line breaks are for readability; use one line and URL-encode the values).

https://receiptroller.io/oauth/authorize
  ?response_type=code
  &client_id={client_id}
  &redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback
  &scope=sales.read%20store.products.read%20store.reservations.read
  &state={state}
  &code_challenge={code_challenge}
  &code_challenge_method=S256

After you sign in and choose Approve, the browser is redirected to http://localhost:8765/callback?code={code}&state={state}. If you choose Deny, the redirect carries ?error=access_denied. The authorization code is valid for 10 minutes and can be used once.

Error (400)Cause and fix
unsupported_response_typeSend response_type=code.
invalid_clientThe client_id is not registered. Check the value from step 2.
invalid_redirect_uriIt does not exactly match the first registered redirect_uris entry.

Step 5: Exchange the code for a token

curl -X POST https://receiptroller.io/api/v1/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code={code} \
  -d redirect_uri=http://localhost:8765/callback \
  -d client_id={client_id} \
  -d client_secret={client_secret} \
  -d code_verifier={code_verifier}
{
  "access_token": "{access_token}",
  "refresh_token": "{refresh_token}",
  "token_type": "Bearer",
  "expires_in": 28800,
  "scope": "sales.read store.products.read store.reservations.read"
}

The response also carries camelCase duplicates such as accessToken for older clients. The access token lasts 8 hours. Although the metadata lists none, a dynamically registered client is a confidential client, so always send client_secret.

Error (401)Cause and fix
invalid_clientThe client_id or client_secret is wrong.
invalid_grantThe code is expired or already used, the redirect_uri differs from the authorization request, code_verifier is missing, or PKCE verification failed. Read error_description and start again from step 4.

This endpoint returns 401, not 400, for invalid_grant. When the token expires, refresh it with grant_type=refresh_token, refresh_token, client_id and client_secret. See Your first API call: from app registration to listing transactions.

Step 6: initialize

curl -X POST https://mcp.receiptroller.io/mcp \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": { "tools": {} },
    "serverInfo": { "name": "Receipt Roller MCP", "version": "0.1.0" }
  }
}

Every response is a single JSON body. There is no session header and no streaming; each request stands alone. The server handles initialize, tools/list, tools/call and ping. Anything else, including notifications/initialized, gets a -32601 error, which you can ignore.

Step 7: tools/list

curl -X POST https://mcp.receiptroller.io/mcp \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "list_my_organizations",
        "description": "List every ReceiptRoller organization the authorized user belongs to. ...",
        "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
        "annotations": { "readOnlyHint": true, "idempotentHint": true }
      },
      { "name": "list_my_stores", "...": "..." },
      { "name": "sales_get_kpis", "...": "..." }
    ]
  }
}

The list contains every tool whatever scopes the token holds. A tool whose scope is missing fails when you call it.

Step 8: tools/call

curl -X POST https://mcp.receiptroller.io/mcp \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_my_organizations","arguments":{}}}'
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [ { "type": "text", "text": "1 organization(s)" } ],
    "structuredContent": {
      "organizations": [
        { "id": "{organizationId}", "name": "Sample Shop", "role": "SuperUser", "roles": ["SuperUser"] }
      ],
      "page": 1,
      "pageSize": 1000,
      "totalCount": 1,
      "totalPages": 1
    }
  }
}

The data is in structuredContent. Pass the id as organizationId to the other tools. For example, to get KPIs:

curl -X POST https://mcp.receiptroller.io/mcp \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sales_get_kpis","arguments":{"organizationId":"{organizationId}"}}}'

Step 9: Call it from Node.js

Given an access token in an environment variable, this script prints the tool list and calls list_my_organizations. It needs no extra packages.

// mcp-check.mjs  —  node mcp-check.mjs
const MCP_URL = "https://mcp.receiptroller.io/mcp";
const accessToken = process.env.RR_ACCESS_TOKEN;
if (!accessToken) {
  console.error("Set RR_ACCESS_TOKEN first.");
  process.exit(1);
}

let nextId = 1;

async function rpc(method, params = {}) {
  const response = await fetch(MCP_URL, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${accessToken}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params })
  });

  if (response.status === 401) {
    throw new Error(`401: ${response.headers.get("www-authenticate")}`);
  }
  const body = await response.json();
  if (body.error) {
    throw new Error(`JSON-RPC ${body.error.code}: ${body.error.message}`);
  }
  return body.result;
}

const { tools } = await rpc("tools/list");
console.log(`${tools.length} tools:`);
for (const tool of tools) console.log(`  ${tool.name}`);

const result = await rpc("tools/call", { name: "list_my_organizations", arguments: {} });
if (result.isError) {
  console.error("Tool error:", result.content[0].text);
  process.exit(1);
}
for (const org of result.structuredContent.organizations) {
  console.log(`${org.id}  ${org.name}  (${org.roles.join(", ")})`);
}
RR_ACCESS_TOKEN={access_token} node mcp-check.mjs

Common errors

401 and WWW-Authenticate

Without an Authorization header the server returns 401 with no body and this header:

WWW-Authenticate: Bearer resource_metadata="https://mcp.receiptroller.io/.well-known/oauth-protected-resource/mcp"

An MCP client follows that URL to the discovery documents and starts authorization. When the token is expired, revoked or unknown, the header also carries error="invalid_token". Refresh the token or authorize again.

JSON-RPC errors and tool errors

What you getMeaning and fix
HTTP 400 with error.code -32700The body is not valid JSON.
error.code -32601The server does not handle that method.
result.isError true, organizationId is required...Call list_my_organizations first and pass the id. Booking tools also need storeId (from list_my_stores).
result.isError true, Upstream 403The token lacks the scope, or the user is not allowed that operation in that business account or store. See 403/401 is returned (permissions and scopes).
result.isError true, Unknown tool: ...The tool name is wrong. Use a name from tools/list.

Next steps

Published: 2026-10-26 Updated: 2026-10-26