Skip to main content
Glama
bharath2020

Concur Expense MCP Server

by bharath2020

Concur Expense MCP Server

An HTTP-transport MCP server that lets an AI agent (primarily Cursor) file and manage SAP Concur expense reports on your behalf — discovering expense types and form fields, creating and filling out expenses, attaching receipts and attendees, and reading everything back. It never submits or approves reports; that step is deliberately left to a human in the Concur web UI.

Prerequisites

  1. Node.js ≥ 20.

  2. Concur app registration (admin-side): a client ID/secret with redirect URI http://127.0.0.1:8765/concur/callback and the scopes your tenant grants (this design was verified against 17 scopes, including the legacy EXPRPT + IMAGE scopes).

  3. Credentials in Keeper: a record holding the client ID, client secret, and a company refresh token; note its record UID.

  4. Keeper Commander installed (pip install keepercommander) with persistent login enabled, so commands run non-interactively:

    keeper shell
    > this-device register
    > this-device persistent-login on
  5. Port 8765 free on the machine running the server — the Concur redirect URI pins it.

Related MCP server: Extend AI Toolkit MCP Server

Setup

npm install
cp .env.example .env

Edit .env. Each secret can be a plain value or a *_COMMAND whose stdout (trimmed) is used as the value — priority is command > plain value > (for the company refresh token only) the persisted store in .secrets/tokens.json:

CONCUR_GEOLOCATION=https://us2.api.concursolutions.com
CONCUR_CLIENT_ID_COMMAND="keeper get <record-uid> --format=json --unmask | jq -r '.custom[]|select(.name==\"client_id\").value'"
CONCUR_CLIENT_SECRET_COMMAND="keeper get <record-uid> --format=json --unmask | jq -r '.custom[]|select(.name==\"client_secret\").value'"
CONCUR_COMPANY_REFRESH_TOKEN_COMMAND="keeper get <record-uid> --format=json --unmask | jq -r '.custom[]|select(.name==\"refresh_token\").value'"
# Plain-value fallbacks also accepted: CONCUR_CLIENT_ID=..., CONCUR_CLIENT_SECRET=..., CONCUR_COMPANY_REFRESH_TOKEN=...

The refresh-token command is bootstrap-only: once the server has run once, it persists token rotations to .secrets/tokens.json and prefers the stored value over the command on every subsequent start.

Setting up the Keeper record

Find an existing record's UID, or create one with the expected custom fields:

keeper search concur        # or: keeper list — note the record UID

keeper record-add --title "Concur MCP" \
  "custom.client_id=<your client id>" \
  "custom.client_secret=<your client secret>" \
  "custom.refresh_token=<your refresh token>"

The *_COMMAND examples above assume custom fields named exactly client_id, client_secret, and refresh_token. If your record uses different names, inspect it and adjust the select(.name==...) filters:

keeper get <record-uid> --format=json --unmask | jq '.custom'

Start the server. The start script does not auto-load .env, so export the variables into the environment first:

set -a; source .env; set +a
npm start

This serves http://127.0.0.1:8765/mcp and prints the bearer-key fallback URL (http://127.0.0.1:8765/auth/login) to the console.

Cursor configuration

Add to ~/.cursor/mcp.json (global) or <project>/.cursor/mcp.json:

{ "mcpServers": { "concur-expense": { "url": "http://127.0.0.1:8765/mcp" } } }

First connect: Cursor gets a 401 challenge, discovers the server's authorization metadata, registers itself via Dynamic Client Registration, and opens a browser for you to sign in to Concur once. The session then binds to that Concur user; your user refresh token is stored, so subsequent connects are silent — no repeated logins.

Fallback for clients whose MCP OAuth is broken, using the static bearer key from http://127.0.0.1:8765/auth/login:

{ "mcpServers": { "concur-expense": {
    "url": "http://127.0.0.1:8765/mcp",
    "headers": { "Authorization": "Bearer <key from http://127.0.0.1:8765/auth/login>" } } } }

Tool catalog

21 tools, registered in src/mcp/buildServer.ts, discoveryTools.ts, writeTools.ts, and readTools.ts.

Session/identity

  • concur_whoami — profile, expense policies, and who the user may act for as a delegate. Call this first in any session.

Discovery

  • concur_list_expense_types — usable (leaf, non-broken) expense types for the acting user's default policy.

  • concur_get_expense_form_fields — writable form fields for a specific expense, with per-field guidance.

  • concur_get_report_form_fields — report-header form fields (name, business purpose, custom fields) for a policy.

  • concur_get_list_values — valid values for a list-type form field, by listId.

  • concur_list_payment_types — payment types (e.g. Cash, Company Card) with the v3 ID needed to create an expense.

  • concur_list_attendee_types — attendee types configured for the acting user (e.g. Business Guest, Spouse).

  • concur_search_locations — search the location catalog (city/venue) to fill the Location field.

Write

  • concur_create_report — create a new expense report header (echoes the created header back).

  • concur_update_report — update an existing report's name and/or business purpose.

  • concur_create_expense — create an expense inside a report; returns the type's form fields.

  • concur_update_expense_fields — fill or change any writable fields on an expense (one or many at once); routes each field to its verified write path and returns a filled-field manifest + web link.

  • concur_add_attendees — add one or more attendees to an expense, merge-preserving anyone already associated (Concur's association POST is a replace). Entries take either an email (matched to an employee via their login, else to a guest via externalId, else creates a guest when lastName+attendeeTypeCode are also given) or lastName+attendeeTypeCode; transactionAmount is optional (omitted amounts auto-split evenly).

  • concur_remove_attendees — remove specific attendees (attendeeIds) or all of them (all: true); Concur has no per-attendee delete, so removal replace-POSTs the remaining list (amounts re-split evenly).

  • concur_attach_receipt — attach a receipt image or PDF to an expense.

Read

  • concur_list_reports — list the acting user's expense reports (legacy v3 listing), filterable by status or creation-date floor.

  • concur_get_report — a report's header, its expenses, and a web deep link.

  • concur_get_expense — a single expense, optionally with a filled/empty field manifest against its type's form.

  • concur_get_expense_attendees — attendees currently associated with an expense.

  • concur_search_attendees — search the attendee catalog: exact by type and/or externalId (the only filters Concur honors), or fuzzy via search=<fragment> (substring match on name/company/externalId over a short-lived guest cache — the API itself has no name search).

Delegate

  • concur_list_delegators — users the session user delegates for, with permission flags (canPrepare, canSubmit, canViewReceipts).

  • Every read/write tool above also accepts an optional onBehalfOfUserId, resolved through the delegate permission gate before anything runs on that person's data.

Deliberately absent: there is no submit or approve tool, at any point in the tool list. Filing stops at a complete, reviewable draft.

The guided flow

Every tool result returns {data, nextSteps: [string], warnings?: [string]}, so the calling agent always knows what to call next and why. The intended sequence:

concur_whoami ─► concur_list_expense_types ─► concur_create_report (or pick via concur_list_reports)
      ─► concur_create_expense (v3 POST: type, date, amount, currency, payment type, core fields)
      ─► concur_get_expense_form_fields (formFields?expenseTypeId=…)   ◄── uses the just-created expense as seed
      ─► concur_get_list_values (per listId field; agent picks by code/value)
      ─► concur_update_expense_fields (v3 PUT for core fields; compact v4 PATCH customData for list/custom fields)
      ─► concur_add_attendees (create/search + associate)   [types whose form requires attendees]
      ─► concur_attach_receipt (Image v1 with v3 key)
      ─► concur_get_expense (verify manifest) ─► repeat for next expense ─► done (never submit)

On create/update, the response includes a filled-field manifest (every field set — label, value, and for list fields the chosen item's code/value — assembled from a post-write read-back) and a web deep link (https://us2.concursolutions.com/nui/expense/report/{reportId}) so the agent can tell the user exactly what was filed and hand them a link to review it.

Testing

npm test        # 126 unit tests (vitest)
npm run typecheck

Two smoke suites validate the design end-to-end against the live Concur stage tenant, run as the logged-in user and as a delegate acting for a permitted delegator (plus a negative case for someone who isn't a delegate):

python3 scripts/smoke-preflight.py self       # API-level preflight, as the logged-in user
python3 scripts/smoke-preflight.py delegate   # API-level preflight, on behalf of a permitted delegator

npx tsx scripts/smoke-mcp.ts self             # MCP-tool-level, against the running server, as the logged-in user
npx tsx scripts/smoke-mcp.ts delegate         # MCP-tool-level, on behalf of a delegator + a negative refusal case

smoke-preflight.py proved the underlying Concur routing directly against the API before implementation (5/5 self, 5/5 delegate: 5 distinct leaf expense types × 5 distinct departments each, every writable required+optional field filled, receipts bound, attendees added where required, deep links generated). smoke-mcp.ts is the MCP-level counterpart — it drives the running server as a real MCP client (no raw Concur HTTP), exercising the actual registered tools end to end, and passed 5/5 self, 5/5 delegate, plus the negative delegate case correctly refused.

Security notes

  • Localhost-only bind: the server listens on 127.0.0.1:8765 only — never on all interfaces — because that's the address baked into the Concur app's registered redirect URI (http://127.0.0.1:8765/concur/callback).

  • Secrets on disk: .secrets/tokens.json (company + per-user tokens) and .secrets/mcp-clients.json (DCR registrations + issued MCP tokens) are written with restrictive permissions (directory 0700, files 0600), atomically, and are git-ignored.

  • Token policy: user-scoped reads and writes always use the session user's own token. The company token is used only for two verified exceptions: (1) non-user config/catalog reads, and (2) the compact v4 expense PATCH for list/custom/orgUnit fields, which Concur's API rejects for user tokens outright (401, "only accessible by a valid company") — the server wraps that call so it only ever runs for the session's authenticated user, or for a delegator after a permission check. The company token never performs user activity outside those two exceptions.

  • Per-session user binding: no tool call executes without an authenticated Concur user bound to the MCP session — company credentials alone never grant tool access.

  • /auth/login reuses the existing session for any local caller: once a Concur user has completed the browser login once, hitting http://127.0.0.1:8765/auth/login again — from any process on the machine — mints a fresh 30-day bearer for that same logged-in user, with no re-authentication. This is deliberate (login-once-reuse UX, not a bug), but it means anyone able to reach 127.0.0.1:8765 can obtain a bearer once a session exists. The mitigation is not to run this server on a shared or multi-user machine.

  • Delegate gating with cross-user refusal: acting on behalf of a delegator requires concur_list_delegators to show canPrepare true for that person first; the server refuses with a clear tool error for anyone else (not a delegate at all, or a delegate without canPrepare), so one user's session can never read or write another user's expense data without a verified delegate relationship.

  • No submit/approve tools, by design: the tool catalog stops at a complete, filled draft. There is no code path anywhere in this server that submits or approves a report — that action is left to a human in the Concur web UI.

F
license - not found
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    -
    quality
    D
    maintenance
    An MCP server that helps AI assistants manage expense-sharing for social events, enabling the creation of gatherings, tracking of expenses, and calculation of fair reimbursements among participants.
    Last updated
    Apache 2.0
  • A
    license
    A
    quality
    D
    maintenance
    An MCP server for Splitwise that enables users to manage shared expenses, friends, and groups directly through AI assistants. It allows for creating, deleting, and listing expenses while providing tools to track net balances and group debts.
    Last updated
    8
    472
    MIT
  • F
    license
    -
    quality
    C
    maintenance
    An AI-powered expense management server that enables adding, searching, and analyzing expenses using natural language through the Model Context Protocol.
    Last updated

View all related MCP servers

Related MCP Connectors

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

  • Log, query, and edit expenses, budgets, and accounts in Ledgy from any MCP-compatible AI assistant.

  • A paid remote MCP for AI agent browser approval MCP, built to return verdicts, receipts, usage logs,

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bharath2020/concur-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server