Skip to main content
Glama
mateusmeloc

mcp-erp-server

by mateusmeloc

mcp-erp-server

CI License: MIT Node >= 20

A reference Model Context Protocol server that lets an AI assistant (Claude, ChatGPT, Claude Code, any MCP client) work with a small business's ERP safely: customers, appointments, invoices and bills.

It runs against an in-memory mock ERP with synthetic data, so you can clone it and try it in a minute. The interesting part is not the mock, it is everything around it: how to expose real business data and write operations to a language model without handing it the keys.

This is a cleaned-up, generic version of patterns I use in production MCP servers for a dental clinic (appointments, receivables, payables). No real data, names or credentials are in this repository.

What it demonstrates

Concern

How it is handled

Where

Remote MCP transport

Stateless Streamable HTTP: a fresh McpServer + transport per request, nothing kept between calls, so it scales horizontally and survives restarts

src/app.ts

Authentication

OAuth 2.1 for MCP clients: protected-resource and authorization-server metadata (RFC 9728 / RFC 8414), dynamic client registration (RFC 7591), authorization code + PKCE S256, refresh tokens. Static per-person bearer tokens also work for scripts and Claude Code

src/auth/

Authorization

Role-scoped tool registration: the role decides which tools exist for that caller. A tool that was never registered cannot be called by mistake, or by a model that was talked into it

src/auth/roles.ts, src/mcp/server.ts

Safe writes

Every write is two steps: a preview that changes nothing, then execution with a confirmation token that is bound to the tool, the person and a hash of the exact arguments, expires, and works once

src/mcp/confirm.ts, src/mcp/writes.ts

Blast-radius controls

Global kill switch (ALLOW_WRITES=false), per-person per-family write rate limit, delete tools kept in their own family that most roles never get

src/mcp/writes.ts

Data minimization

Search results and profiles return masked personal data; full contact details live behind a separate tool that only some roles can reach and that is audited

src/erp/privacy.ts

Audit trail

Every preview, execution, denial and sensitive read is logged as structured JSON with ids and amounts, never personal data. The logger also redacts anything that looks like a credential

src/mcp/audit.ts, src/logger.ts

Testing

40 tests: unit, tool-level through a real MCP client, and end-to-end over HTTP including the full OAuth flow

test/

Related MCP server: tessera-mcp

Quick start

git clone https://github.com/mateusmeloc/mcp-erp-server.git
cd mcp-erp-server
npm install
cp .env.example .env     # demo values only; see "Configuration"
set -a; . ./.env; set +a
npm run dev              # http://localhost:3000

Check it is alive and that it asks for credentials:

curl -s localhost:3000/healthz
curl -si -X POST localhost:3000/mcp | head -n 3     # 401 + WWW-Authenticate

Then talk to it with the demo admin key from .env.example:

curl -s localhost:3000/mcp \
  -H "Authorization: Bearer change-me-demo-admin-token-0001" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Or point the MCP Inspector at http://localhost:3000/mcp.

npm test          # 40 tests, no network needed
npm run typecheck
npm run build && npm start

Tools

Tool

Family

Read / write

What it does

erp_status

system

read

Who you are connected as, your role, and whether write tools are enabled

customer_search

customers

read

Find customers by name, id or city. Returns who they are, never contact details

customer_get

customers

read

A profile with email, phone and document masked

customer_get_contact

customers-pii

read, audited

Full contact details of one customer; every call is logged with the caller's stated reason

appointments_list

scheduling

read

Appointments in a date range (max 62 days), filterable by staff and status

appointments_availability

scheduling

read

Free start times for one staff member on one day (30-minute grid, working hours)

appointment_book

scheduling-write

write (preview + confirm)

Books a slot

appointment_cancel

scheduling-write

write (preview + confirm)

Cancels an appointment

invoices_list

finance

read

Invoices by status, customer and due-date range

receivables_summary

finance

read

Open invoices aged: not yet due, 1-30, 31-60, 61+ days overdue

cashflow_forecast

finance

read

Expected money in and out in weekly buckets; overdue amounts reported separately

payables_list

finance

read

Bills to pay, sorted by due date

invoice_mark_paid

finance-write

write (preview + confirm)

Registers a payment

payable_create

finance-write

write (preview + confirm)

Creates a bill

payable_delete

finance-delete

write (preview + confirm)

Permanently deletes an open bill. Its own family on purpose

Roles

Role

Gets

Typical use

admin

Every family, including ones added in the future

The owner

manager

Everything except finance-delete

Runs the day to day, including money

frontdesk

System, customers (masked), scheduling and its writes. No money

Reception

agent

Scheduling and its writes only

An automated assistant that talks to strangers (a chat bot)

admin is the only open-ended role. The others are closed lists, so a family added tomorrow is invisible to them until someone grants it deliberately.

How a write works

 model ──► appointment_book(customer_id, staff, start)  ──► PREVIEW: "Book a <service> for Alex Rivera
                                                           (C-001) with <staff> on <start>, 30 minutes."
                                                           + confirmation_token   (nothing changed)
 human sees the preview and approves
 model ──► appointment_book(same args, confirm=true,
                            confirmation_token=…)      ──► executed (token consumed, audited)

The token is HMAC-signed and carries a hash of tool | person | canonical(args), an expiry and a unique id. So the model cannot skip the preview, cannot run something different from what the human saw (any changed argument invalidates it), cannot use someone else's token, and cannot replay an old approval. State is revalidated at execution time, because the calendar may have changed between preview and confirm.

The order inside runWrite is fixed, in one place, so the guardrails cannot drift apart between tools: kill switch, rate limit, revalidate, preview or consume token, run, audit.

Connecting a client

The server exposes one endpoint, POST /mcp, plus the OAuth endpoints. Deploy it behind HTTPS (OAuth redirects and bearer tokens must never travel in clear text) and set PUBLIC_URL to the public address.

  • Claude / ChatGPT (custom connector): add https://your-host/mcp as a remote MCP server. The client discovers the OAuth metadata, registers itself, and sends the person to the consent page, which asks for their own access key (the token from ERP_USERS). The consent page names the client and the host it will redirect to.

  • Claude Code: claude mcp add --transport http erp https://your-host/mcp --header "Authorization: Bearer <access key>"

  • Scripts: send Authorization: Bearer <access key> directly.

Configuration

Everything comes from environment variables and is validated at boot; a bad configuration stops the process with a clear message instead of running half-configured. See .env.example.

Variable

Default

Meaning

TOKEN_SECRET

required

At least 32 chars. Signs OAuth tokens and confirmation tokens

ERP_USERS

required

JSON array: [{"name":"alice","role":"admin","token":"…"}]. One token per person (at least 24 chars) so revoking one does not affect the others

PORT

3000

PUBLIC_URL

request host

Public base URL used in the OAuth metadata

ALLOW_WRITES

true

Global kill switch for every write tool

TRUST_PROXY

false

Set to 1 behind a reverse proxy so rate limits see the real client IP

WRITES_PER_MINUTE

10

Per person, per write family

CONFIRM_TTL_SECONDS

300

How long a preview stays confirmable

ACCESS_TTL_SECONDS

3600

OAuth access token lifetime

REFRESH_TTL_SECONDS

2592000

OAuth refresh token lifetime (30 days)

With NODE_ENV=production the server refuses to start if any credential contains the demo marker change-me.

Revoking access: remove the person from ERP_USERS and restart. Roles are read from the current configuration on every request, not from the token, so the change takes effect immediately for OAuth tokens too. Rotating TOKEN_SECRET revokes every issued token at once.

Project layout

src/
  app.ts            Express app: /mcp (stateless), /healthz, error handling
  config.ts         Env parsing and validation (fails loudly at boot)
  auth/             roles, signed tokens, identity, OAuth 2.1 server + consent page
  erp/              the mock ERP (types, store with deterministic seed data, privacy masking)
  mcp/              server factory, write pipeline, confirmations, audit
    tools/          one file per family
test/               unit, tool-level (real MCP client) and HTTP/OAuth end-to-end tests

To use it with a real system, replace src/erp/store.ts with a client for your ERP and keep the tool layer, roles and write pipeline as they are.

Limits you should know about

This is a reference implementation, not a turnkey product. Honest list:

  • Single instance. Rate limiters, OAuth authorization codes, registered clients and the single-use ledger of confirmation tokens live in memory. Behind several replicas, or across restarts, they are not shared (a restart invalidates pending previews and codes, which fails safe). Move them to Redis or a database before scaling out.

  • Refresh tokens are not one-time. A new pair is issued on every refresh, but the server is stateless, so the previous refresh token stays valid until it expires. Strict rotation needs a store.

  • Dynamic client registration is open, as DCR is by design, so it is treated as untrusted: redirect URIs must be https or loopback and are matched exactly, codes are single use and bound to client + redirect URI + PKCE, and nothing is granted without a valid access key. If you need stricter control, add an allow-list of clients.

  • Access keys are shared secrets, not an identity provider. For a team, put this behind your SSO or replace auth/identity.ts.

  • The data is fake. Money is USD, times are UTC, the business is a generic service shop.

License

MIT

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    C
    maintenance
    A production-grade MCP server for a fictional digital bank, exposing tools for an AI copilot to service customers across the full risk spectrum from read-only lookups to money movement and destructive admin actions, with OAuth 2.1 security and a realistic dataset.
    13 npm
    1
    -
  • A
    license
    A
    quality
    D
    maintenance
    Enables AI agents to manage a fictional B2B workspace SaaS (Tessera) with tools for ticketing, invoicing, customer management, and trial extensions, featuring a human-in-the-loop confirm pattern for safety.
    7
    7 npm
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI assistants to manage customer support for a fictional candle shop, including order lookup, customer file access, and safe refund processing with server-side safety rules.
    MIT
  • F
    license
    A
    quality
    B
    maintenance
    Enables an AI agent to handle accounts payable tasks against a mock ERP, including reading and writing bills and vendors, checking duplicates, matching invoices, recommending approvals, and queuing payment releases, with configurable profiles that limit available tools.
    11
    -