Skip to main content
Glama
bharath2020

Concur Expense MCP Server

by bharath2020
README.md
# 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.

## Setup

```bash
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`:

```bash
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:

```bash
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:

```bash
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:

```bash
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`:

```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`:

```json
{ "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

```bash
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):

```bash
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.