Concur Expense MCP Server
An MCP server for managing SAP Concur expense reports, allowing AI agents to create and update expenses, attach receipts and attendees, and read report data, while leaving submission and approval to humans.
README
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
- Node.js ≥ 20.
- Concur app registration (admin-side): a client ID/secret with redirect URI
http://127.0.0.1:8765/concur/callbackand the scopes your tenant grants (this design was verified against 17 scopes, including the legacyEXPRPT+IMAGEscopes). - Credentials in Keeper: a record holding the client ID, client secret, and a company refresh token; note its record UID.
- 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 - Port 8765 free on the machine running the server — the Concur redirect URI pins it.
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, bylistId.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 anemail(matched to an employee via their login, else to a guest viaexternalId, else creates a guest whenlastName+attendeeTypeCodeare also given) orlastName+attendeeTypeCode;transactionAmountis 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/orexternalId(the only filters Concur honors), or fuzzy viasearch=<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:8765only — 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 (directory0700, files0600), 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
PATCHfor 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/loginreuses the existing session for any local caller: once a Concur user has completed the browser login once, hittinghttp://127.0.0.1:8765/auth/loginagain — 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 reach127.0.0.1:8765can 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_delegatorsto showcanPreparetrue for that person first; the server refuses with a clear tool error for anyone else (not a delegate at all, or a delegate withoutcanPrepare), 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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。