ExpenseBot

How can I build an MCP client integration with ExpenseBot?

Connect your MCP client without an approval email. Register exact HTTPS callbacks, verify their origin, then let each customer sign in and consent. Pending registration grants no access. Shared callback services require separate review.

Connect your MCP client without an approval email. Register exact HTTPS callbacks, verify their origin, then let each customer sign in and consent. Pending registration grants no access. Shared callback services require separate review.

Who this is for

This path is for a developer building an MCP client that connects to ExpenseBot. It does not register or host a new MCP server. For an existing assistant or desktop client, use the ExpenseBot MCP setup guide instead.

The self-service flow lets a developer register the client and prove control of its HTTPS callback origin. A successful domain proof is not a security endorsement, partner approval, or access to any ExpenseBot account. Each customer must still sign in and explicitly consent to the scopes requested by the client.

Environments and callback

Use one OAuth registration per environment. These are synthetic examples; .example is reserved and cannot pass the live DNS proof. Replace each host with an HTTPS origin you control that has public IPv4 reachability before using the commands below.

EnvironmentSynthetic originExact callback URI
Productionhttps://api.integrator.examplehttps://api.integrator.example/api/connector/expensebot/callback
Staginghttps://st-api.integrator.examplehttps://st-api.integrator.example/api/connector/expensebot/callback
Developmenthttps://dev-api.integrator.examplehttps://dev-api.integrator.example/api/connector/expensebot/callback

Each callback URI must be exact and have no query or fragment. Every callback in one registration must share its single HTTPS origin. Keep the client ID and management token separate for each environment. Existing approved integrations keep their prior approval and must not repeat this proof flow.

Use a public OAuth client: authorization code with PKCE S256 and token endpoint authentication method none. Do not create or distribute a client secret. Request only needed scopes; start with expenses.read. A shared callback service used by unrelated integrators cannot prove one integrator's control and remains a manual-review case.

Register and Prove

Use the registration response to determine your next step. A new callback needs proof before customer authorization; an already approved integration follows its existing setup.

Set the origin and exact callback for one environment. Repeat the process independently for staging and development; never copy a client ID or management token between environments.

MCP_BASE="https://mcp.expensebot.ai"
CALLBACK_URI="https://api.integrator.example/api/connector/expensebot/callback"
CLIENT_NAME="Example ExpenseBot Connector (production)"

REGISTER_RESPONSE=$(curl -sS -X POST "$MCP_BASE/oauth/register" \
  -H 'Content-Type: application/json' \
  --data-binary @- <<JSON
{
  "client_name": "$CLIENT_NAME",
  "redirect_uris": ["$CALLBACK_URI"],
  "token_endpoint_auth_method": "none",
  "scope": "expenses.read"
}
JSON
)

printf '%s\n' "$REGISTER_RESPONSE" | jq 'del(.management_token, .challenge)'
CLIENT_ID=$(printf '%s' "$REGISTER_RESPONSE" | jq -r '.client_id')
MANAGEMENT_TOKEN=$(printf '%s' "$REGISTER_RESPONSE" | jq -r '.management_token')
CHALLENGE=$(printf '%s' "$REGISTER_RESPONSE" | jq -r '.challenge')
PROOF_URL=$(printf '%s' "$REGISTER_RESPONSE" | jq -r '.proof_url')

When admitted, a successful registration returns HTTP 202, status: "pending", client_id, management_token, challenge, proof_url, expires_at, guide_url, and next_step. The challenge expires 30 minutes after registration. The management token is returned once; store it in a secret manager. It is not an OAuth access or refresh token and cannot call MCP tools.

Publish the challenge as the response body at the fixed path /.well-known/expensebot-mcp-verification on the registered origin. The following uses a local static-site public/ directory as an example; use the equivalent public directory or route in your service. Do not add a newline or wrap the challenge in JSON or HTML.

mkdir -p public/.well-known
printf '%s' "$CHALLENGE" > public/.well-known/expensebot-mcp-verification

Deploy that static file to the origin, then confirm it is served as plain text with HTTP 200 and the same content before asking ExpenseBot to fetch it:

curl -i "$PROOF_URL"

Check registration status without performing a proof fetch:

MANAGEMENT_JSON=$(jq -n \
  --arg client_id "$CLIENT_ID" \
  --arg management_token "$MANAGEMENT_TOKEN" \
  '{client_id:$client_id,management_token:$management_token}')

curl -sS -X POST "$MCP_BASE/oauth/register/status" \
  -H 'Content-Type: application/json' \
  --data-binary "$MANAGEMENT_JSON" | jq .

Submit the proof only after the challenge is live at proof_url:

curl -sS -X POST "$MCP_BASE/oauth/register/verify" \
  -H 'Content-Type: application/json' \
  --data-binary "$MANAGEMENT_JSON" | jq .

Successful status and verification calls return HTTP 200. A pending status includes status: "pending", client_id, proof_url, expires_at, guide_url, and next_step. After proof succeeds, the response has status: "registered", client_name, client_id_issued_at, redirect_uris, token_endpoint_auth_method, grant_types, response_types, scope, proof_url, guide_url, and unreviewed: true. A pending registration cannot authorize a customer or mint tokens.

ExpenseBot fetches only the fixed proof path over HTTPS, without redirects, and checks the bounded response body against the challenge. Public IPv4 reachability is required. Dual-stack origins are supported when their public A records pass validation; IPv6-only origins are not supported. All selected A records are validated and the request is pinned to a validated IPv4 address.

Verification allows five attempts with a 10-second cooldown between attempts. A wrong proof or failed fetch consumes an attempt. 410 invalid_or_expired_proof means the pending proof expired or bound metadata changed. 400 proof_not_verified means the fetched proof did not match or could not be safely fetched. 429 includes Retry-After; five exhausted attempts require a fresh registration, and source rate limits require waiting for the indicated interval.

There is no registration mutation endpoint. If a callback, client name, scope, or other bound metadata changes, register a new client and prove the new origin; do not edit or reuse the old proof. If the management token is lost, it cannot be retrieved; create a fresh registration. Status remains available with the valid management token when the admission flag is off, including for already verified clients. The flag gates new self-service admission and proof verification; it does not disable status lookup or existing approved integrations. If the pending challenge expires, begin with a fresh registration and host its new challenge.

OAuth Discovery and PKCE

Read the OAuth authorization-server and protected-resource metadata. These publish the authorization, token, registration, revocation, MCP resource, and supported-scope details:

AS_METADATA=$(curl -fsS "$MCP_BASE/.well-known/oauth-authorization-server")
RESOURCE_METADATA=$(curl -fsS "$MCP_BASE/.well-known/oauth-protected-resource")
printf '%s\n' "$AS_METADATA" | jq '{authorization_endpoint,token_endpoint,registration_endpoint,revocation_endpoint,code_challenge_methods_supported,token_endpoint_auth_methods_supported,scopes_supported}'
printf '%s\n' "$RESOURCE_METADATA" | jq '{resource,authorization_servers,scopes_supported}'
AUTHORIZATION_ENDPOINT=$(printf '%s' "$AS_METADATA" | jq -r '.authorization_endpoint')
TOKEN_ENDPOINT=$(printf '%s' "$AS_METADATA" | jq -r '.token_endpoint')

Generate and retain a high-entropy verifier. The client sends its SHA-256 base64url challenge to authorization and keeps the verifier private until token exchange:

CODE_VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)

AUTH_URL=$(node -e '
const [endpoint,clientId,callback,state,challenge]=process.argv.slice(1);
const u=new URL(endpoint);
for (const [k,v] of Object.entries({response_type:"code",client_id:clientId,redirect_uri:callback,scope:"expenses.read",state,code_challenge:challenge,code_challenge_method:"S256",resource:"https://mcp.expensebot.ai"})) u.searchParams.set(k,v);
console.log(u.toString());
' "$AUTHORIZATION_ENDPOINT" "$CLIENT_ID" "$CALLBACK_URI" "$STATE" "$CODE_CHALLENGE")
printf '%s\n' "$AUTH_URL"

Open the printed URL in a browser. The ExpenseBot user signs in, reviews the named integration and expenses.read permission, and explicitly consents. The browser returns to the exact callback URI with a short-lived code and the original state. The integration must verify state matches before continuing. Capture the code in the callback handler; do not log or share it.

Exchange the code using the same client ID, exact callback, and original verifier. A public client sends no client secret:

TOKEN_JSON=$(jq -n \
  --arg code "$AUTH_CODE" \
  --arg callback "$CALLBACK_URI" \
  --arg client_id "$CLIENT_ID" \
  --arg verifier "$CODE_VERIFIER" \
  '{grant_type:"authorization_code",code:$code,redirect_uri:$callback,client_id:$client_id,code_verifier:$verifier,resource:"https://mcp.expensebot.ai"}')
TOKEN_RESPONSE=$(curl -sS -X POST "$TOKEN_ENDPOINT" \
  -H 'Content-Type: application/json' \
  --data-binary "$TOKEN_JSON")
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -r '.access_token')
REFRESH_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -r '.refresh_token')
unset CODE_VERIFIER CODE_CHALLENGE TOKEN_JSON TOKEN_RESPONSE

The response contains access_token, refresh_token, token_type: "Bearer", expires_in, and scope. Keep both tokens in a secret store; never print them in application logs or place them in a URL.

First Read-only MCP Calls

The remote MCP endpoint is https://mcp.expensebot.ai/mcp. These Streamable HTTP examples use the stateless endpoint; MCP SDKs normally perform the same protocol steps for you. The server accepts protocol version 2024-11-05 (also used by its protocol tests). Initialize, then list tools:

MCP_URL="$MCP_BASE/mcp"
curl -sS -X POST "$MCP_URL" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-binary '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"integrator-example","version":"1.0.0"}}}'

curl -sS -X POST "$MCP_URL" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2024-11-05' -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-binary '{"jsonrpc":"2.0","method":"notifications/initialized"}' -o /dev/null

curl -sS -X POST "$MCP_URL" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2024-11-05' -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-binary '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

The remote transport may return a text/event-stream response with the JSON-RPC reply in a data: {...} event, not a bare JSON document. Do not pipe these raw MCP responses to jq. An MCP SDK handles the stream; for manual inspection, read the data: event. The repository smoke-test helper parses either SSE or JSON responses.

For a first authenticated read, call list_categories. It takes no arguments, requires expenses.read, and returns the user's configured categories and billing accounts; it does not change records:

curl -sS -X POST "$MCP_URL" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2024-11-05' -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-binary '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_categories","arguments":{}}}'

The OAuth user still controls access through consent and can disconnect the integration by revoking its tokens.

Refresh, Revoke, and Recover

Access tokens expire. Use the refresh token to obtain a replacement pair; refresh tokens rotate, so securely replace both stored tokens with the response values:

REFRESH_JSON=$(jq -n \
  --arg client_id "$CLIENT_ID" \
  --arg refresh_token "$REFRESH_TOKEN" \
  '{grant_type:"refresh_token",client_id:$client_id,refresh_token:$refresh_token,resource:"https://mcp.expensebot.ai"}')
TOKEN_RESPONSE=$(curl -sS -X POST "$TOKEN_ENDPOINT" \
  -H 'Content-Type: application/json' --data-binary "$REFRESH_JSON")
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -r '.access_token')
REFRESH_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -r '.refresh_token')
unset REFRESH_JSON TOKEN_RESPONSE

If refresh is expired, revoked, or rejected, send the user through authorization and consent again with a new PKCE verifier. If a pending registration expires or proof attempts are exhausted, create a fresh registration and publish its new challenge. If an access or refresh token may have leaked, revoke both separately through the standard OAuth revocation endpoint. The endpoint accepts a JSON token field and returns HTTP 200 with an empty JSON object for known or unknown tokens:

for TOKEN in "$ACCESS_TOKEN" "$REFRESH_TOKEN"; do
  curl -sS -X POST "$MCP_BASE/oauth/revoke" \
    -H 'Content-Type: application/json' \
    --data-binary "$(jq -n --arg token "$TOKEN" '{token:$token}')"
done
unset ACCESS_TOKEN REFRESH_TOKEN

Revocation applies to the submitted access or refresh token, not the client registration; submit both tokens to stop access and prevent refresh. This onboarding flow has no client-registration mutation or self-service client-revocation endpoint. For a callback change, make a new registration and proof. For a lost management token or a registration that must be retired, contact ExpenseBot support; do not send management credentials, OAuth tokens, or customer data.

If registration reports admission unavailable, follow its next_step and contact support rather than retrying continuously. Temporarily pausing new registrations does not revoke already approved integrations or prevent management-authenticated status checks.

Share:

Try ExpenseBot Free

AI extracts every receipt into a Google Sheet you own. Gmail scan, mileage, tax reports, profit-by-client. No credit card needed.

No credit card required · Setup in 30 seconds