Skip to main content
Glama
ohrytskov

stripe-reporting-mcp

by ohrytskov
README.md
# Stripe Reporting

Read-only Stripe finance, ops, and risk reporting — exposed three ways from
one TypeScript backend:

- **MCP server** (stdio) for AI agents in Claude Code and other MCP clients
- **HTTP API** (Express) for dashboards, scripts, and non-MCP integrations
- **CLI** (`stripe-reporting`) for ad-hoc inspection, scripts, and CI checks

All three transports share the same reporting backend, the same demo/stripe
modes, and write to the same MongoDB audit log.

This project is designed as a **demo-first reporting layer** for teams that
need visibility into Stripe without the ability to move money. It normalizes
balances, payments, customers, payouts, reconciliation, and risk alerts.

[![Same query, three transports](docs/captures/images/01-three-transports.png)](docs/captures/images/01-three-transports.gif)

> _Same backend, three front doors. Click for the full capture._
> See also [`docs/walkthrough.md`](docs/walkthrough.md),
> [`docs/architecture.md`](docs/architecture.md),
> and [`docs/decisions.md`](docs/decisions.md).

## What It Is

- Read-only Stripe reporting backend built with TypeScript
- Three transports — MCP, HTTP API, CLI — all backed by the same service layer
- Works in `demo` mode with seeded fixtures for local testing
- Works in `stripe` mode with a live `STRIPE_SECRET_KEY`
- Returns both human-readable summaries and structured JSON
- Includes deterministic risk rules instead of vague AI-only scoring
- Optional MongoDB-backed audit log captures every MCP / HTTP / CLI call

## What It Is Not

- It does not create charges, invoices, refunds, or payouts
- It does not mutate Stripe state in any way
- It is not a treasury or stablecoin transfer agent
- It is not claiming production-hardening beyond demo and local validation

## Tools

| Tool | Purpose |
| --- | --- |
| `get_balance_summary` | Return available and pending balances by currency |
| `get_revenue_summary` | Return gross, refund, and net revenue totals over time |
| `list_payments` | List normalized payment records with filters and pagination |
| `get_payment` | Return details for a single payment, including refunds and disputes |
| `list_customers` | List customers with derived payment summaries |
| `get_customer_payments` | Return a customer summary plus payment history |
| `list_payouts` | List payout records with filters and pagination |
| `get_reconciliation_report` | Return payment, refund, fee, payout, and balance totals for reconciliation |
| `get_risk_alerts` | Return deterministic risk alerts such as failure spikes and payout delays |

## Resources

- `ops://capabilities`
- `finance://glossary`
- `risk://rules`

## Modes

[![Demo mode → Stripe mode, same shape](docs/captures/images/02-demo-to-stripe.png)](docs/captures/images/02-demo-to-stripe.gif)

### `demo`

Uses seeded fixtures so you can test the full MCP experience without Stripe credentials.

### `stripe`

Uses the Stripe API in read-only fashion through your secret key.

Current live-mode behavior:
- reads balances, charges, customers, refunds, disputes, payouts, and balance transactions
- normalizes Stripe objects into a stable MCP response shape
- is intended for demo and internal tooling workflows, not a compliance-grade reporting system

## Quick Start

Install and validate locally:

```bash
npm install
npm test
npm run self-check
npm run build
```

Run in demo mode:

```bash
npm run dev:mcp
```

Run in live Stripe mode:

```bash
export MCP_MODE=stripe
export STRIPE_SECRET_KEY=sk_test_...
npm run dev:mcp
```

## Environment

See [env.example](./env.example) for the supported variables.

Main variables:
- `MCP_MODE=demo|stripe`
- `STRIPE_SECRET_KEY=...`
- `MCP_DEFAULT_LOOKBACK_DAYS=30`
- `MCP_DEFAULT_PAGE_SIZE=20`
- `MCP_MAX_PAGE_SIZE=100`
- `MCP_STRIPE_MAX_PAGES=10`
- `HTTP_HOST=127.0.0.1` (loopback only by default; set to `0.0.0.0` to expose)
- `HTTP_PORT=3000`
- `MONGODB_URI=mongodb://localhost:27017` (required for storage primitives + audit log)
- `MONGODB_DB_NAME=stripe_reporting`
- `STRIPE_REPORTING_AUTH_REQUIRED=false` (set to `true` to gate storage with Google OAuth)
- `STRIPE_REPORTING_TOKEN_FILE=~/.stripe-reporting/credentials.json`
- `STRIPE_REPORTING_TOKEN_TTL_DAYS=90` (issued tokens expire after this many days)
- `GOOGLE_OAUTH_CLIENT_ID=...` (only when `AUTH_REQUIRED=true`)
- `GOOGLE_OAUTH_CLIENT_SECRET=...` (only when `AUTH_REQUIRED=true`)

## Audit logging in MongoDB

[![Risk alerts + audit log tail](docs/captures/images/03-risk-and-audit.png)](docs/captures/images/03-risk-and-audit.gif)

When `MONGODB_URI` is set, every call across **all three transports** —
HTTP, MCP, and CLI — is recorded in a MongoDB `requestlogs` collection
(endpoint, method, query, status, duration, error code/message, timestamp).

Distinguish them via the `method` field:

```js
db.requestlogs.find({method: "GET"})   // HTTP
db.requestlogs.find({method: "MCP"})   // MCP tool / resource invocations
db.requestlogs.find({method: "CLI"})   // CLI subcommand runs
```

For MCP, `endpoint` is `tool:<name>` (e.g. `tool:get_balance_summary`) or
`resource:<uri>` (e.g. `resource:ops://capabilities`). Tool failures land
with `status: 400` for `invalid_request`/`invalid_config` and `status: 500`
otherwise; the `errorCode`/`errorMessage` mirror the AppError surface.

Logging is fully best-effort and asynchronous — Mongo write failures are
logged to stderr but never break the reporting response.

## Persistent memory: schema-on-the-fly storage

[![Schema-on-the-fly with save / find](docs/captures/images/04-schema-on-the-fly.png)](docs/captures/images/04-schema-on-the-fly.gif)

This is what makes the combo (MCP + MongoDB + Express + AI agents) more
than the sum of its parts: **the agent has memory that survives sessions,
shared across all three transports, audited like everything else.**

Five primitive operations exposed identically through MCP, HTTP, and CLI:

| Operation | MCP tool | HTTP endpoint | CLI command |
| --- | --- | --- | --- |
| Save a JSON document (collection auto-created) | `save` | `POST /storage/:collection/save` | `save <collection> <json>` |
| Find documents by filter | `find` | `POST /storage/:collection/find` | `find <collection> [--filter]` |
| List user's collections + counts | `list_collections` | `GET /storage/collections` | `list-collections` |
| Delete by filter (no empty filter) | `delete` | `POST /storage/:collection/delete` | `delete <collection> --filter` |
| Explicitly create a collection | `create_collection` | `POST /storage/collections` | `create-collection <name>` |

**Schema is created on the fly.** A new collection appears the first time
you save into it. Mongo's permissiveness is the right tool here — agents
can introduce new buckets without migrations as the conversation evolves.
Validation: collection names match `/^[a-z][a-z0-9_]{2,63}$/`; the system
collections (`requestlogs`, `users`) are reserved.

**Every saved document is automatically tagged with `_userId` and
`_createdAt`.** Find/delete filters are scoped to the caller's `_userId`
so users can't read or delete each other's data. When auth is off
(`STRIPE_REPORTING_AUTH_REQUIRED=false`) all docs share `_userId: "default"`
— fine for single-user demos.

### What an agent might save

Examples of natural agent + user collaborations during a chat:

- *"Pin this report as 'Monday morning check'."* → `save("pinned_queries", {name, tool, args})`
- *"Flag these three customers — I want to look at them again next session."* → `save("flagged_customers", {customer_id, reason})`
- *"Note that customer X is approved out-of-band by the CFO."* → `save("annotations", {entity_type, entity_id, note})`
- *"Open a case for this dispute."* → `save("cases", {dispute_id, evidence: [...], status: "open"})`
- *"Tag these 50 customers as 'high-LTV cohort'."* → `save("cohorts", {name, members})`
- *"Lock April's books here, with these numbers."* → `save("closed_periods", {period, totals})`

None of these collections need to exist beforehand. The agent reaches for
`save("flagged_customers", ...)` and the bucket appears. Next session,
the agent calls `list_collections` to see what the user has accumulated,
and `find` to read it back.

> **Important — passive storage, not active monitoring.** These primitives
> save/read/delete documents. Nothing scans Stripe periodically and fires
> alerts. A "flagged customer" is a saved row; the agent only acts on it
> when *you ask*, e.g. *"go check on the customers I flagged last week."*
> True monitoring would require a scheduler/worker — see *Future work*
> in this README.

### Demo flow

```bash
# Empty state
mongosh stripe_reporting --eval "show collections"
# -> requestlogs

# Agent (or user) flags a customer to revisit
stripe-reporting save flagged_customers '{"customer_id": "cus_abc", "reason": "fraud risk"}'

# Schema appeared on the fly
mongosh stripe_reporting --eval "show collections"
# -> requestlogs, flagged_customers

# Read it back
stripe-reporting find flagged_customers --pretty

# Same thing via HTTP — same Mongo, same answer
curl -X POST http://127.0.0.1:3000/storage/flagged_customers/find \
  -H 'content-type: application/json' -d '{}' | jq

# Same thing via MCP — Claude Code calls the `find` tool

# Audit trail
mongosh stripe_reporting --eval 'db.requestlogs.find({endpoint: /tool:save|tool:find/}).pretty()'
```

## Authentication (optional)

[![Auth lifecycle: whoami, expiry, logout](docs/captures/images/05-auth-lifecycle.png)](docs/captures/images/05-auth-lifecycle.gif)


By default the demo is **single-user, local-only, no auth**. Storage
primitives all use `_userId = "default"` and the HTTP server binds to
`127.0.0.1`. This is the right shape for *"my personal Stripe co-pilot
running on my laptop."*

When you want multi-user, flip one env var:

```
STRIPE_REPORTING_AUTH_REQUIRED=true
GOOGLE_OAUTH_CLIENT_ID=...
GOOGLE_OAUTH_CLIENT_SECRET=...
```

### Setting up the Google OAuth client (~5 minutes, free)

1. Go to <https://console.cloud.google.com/apis/credentials>.
2. Create or select a project.
3. Configure the **OAuth consent screen** if prompted: User type
   "External", fill in app name + support email, save. You don't need
   verification for personal/internal use — the app stays in "Testing"
   mode and you add yourself as a test user.
4. Click **Create credentials → OAuth client ID**.
5. Choose **Application type: Desktop app** (this is important — the
   loopback-redirect flow this CLI uses requires the Desktop type, not
   Web). Name it anything, e.g. *"stripe-reporting CLI"*.
6. Copy the resulting **Client ID** and **Client secret** into your
   `.env`:

```
STRIPE_REPORTING_AUTH_REQUIRED=true
GOOGLE_OAUTH_CLIENT_ID=<client id>.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=<client secret>
```

That's it — no redirect URIs to register. Google allows any
`http://localhost:<port>` redirect for Desktop-type clients without
pre-registration.

### Running the login flow

Once per user, on each machine:

```bash
stripe-reporting login
# Opens browser, signs in with Google, saves an API token to
# ~/.stripe-reporting/credentials.json (mode 0600)

stripe-reporting whoami
# {"authenticated": true, "userId": "google_<sub>", "email": "alice@…",
#  "expiresAt": "...", "expiresInDays": 90}

stripe-reporting logout
# Revokes the token server-side, deletes the local credentials file.
```

**Token expiry.** Tokens issued at login expire after
`STRIPE_REPORTING_TOKEN_TTL_DAYS` (default 90). When a token expires,
HTTP requests get `401 auth_expired` and `whoami` surfaces the
failure — re-run `stripe-reporting login` to issue a fresh token.
Pre-existing tokens issued before this field was introduced have no
expiry set; they continue to work indefinitely until you log out.

After login:

- HTTP requires `Authorization: Bearer <api-token>` on every request
- MCP startup reads the token from the credentials file → tags storage
  by the authenticated user
- CLI subcommands read the same credentials file
- Storage docs are scoped by Google `sub` (stable across email changes,
  rotations, etc.)
- Audit log entries are tagged with `userId` — filter the
  `requestlogs` collection by `{userId: "<google-sub>"}`

**Identity model.** Google answers "who is this." Stripe credentials
answer "what data can they read." The two are intentionally decoupled —
the Stripe key stays in env, never crosses the wire to our server.

**Threat model.** With `HTTP_HOST=127.0.0.1` (default) the API is only
reachable from `localhost`, so even without auth the data is as safe as
your shell account. Set `HTTP_HOST=0.0.0.0` *only* if you've also set
`AUTH_REQUIRED=true`. For genuine multi-tenant hosted deployment,
upgrade the bearer-token flow to Stripe Connect OAuth — the auth
boundary is one middleware (`src/middleware/auth.ts`); the rest of the
system is unchanged.

## HTTP API (Express + MongoDB)

In addition to the MCP transport, the same reporting backend is exposed as a
read-only HTTP API. When `MONGODB_URI` is set, every request is recorded in a
MongoDB `requestlogs` collection.

Run it locally:

```bash
npm run dev:http        # tsx, demo mode
# or, after npm run build:
npm run start:http
```

> **Startup note:** the HTTP server takes ~5 seconds to bind on first launch
> because Mongoose has a heavy module-init cost. If you script against it
> (CI, smoke tests, health checks), wait for the
> `[http] ... listening on :<port>` line on stdout — or poll `/health` with
> a retry — before sending real traffic. The MCP entrypoint (`dev:mcp`) is
> not affected; it does not import Mongoose.

Endpoints (all `GET`):

| Endpoint | Notes |
| --- | --- |
| `/health` | Returns mode, mongo connection state, version |
| `/capabilities` | Equivalent of `ops://capabilities` |
| `/balance/summary` | `?as_of=&currency=` |
| `/revenue/summary` | `?from=&to=&currency=&group_by=day|week|month&include_refunds=true` |
| `/payments` | `?from=&to=&status=&customer_id=&email=&currency=&limit=&starting_after=` |
| `/payments/:id` | Single payment with refunds and disputes |
| `/customers` | `?email=&created_from=&created_to=&limit=&starting_after=` |
| `/customers/:id/payments` | `?from=&to=&status=&limit=&starting_after=` |
| `/payouts` | `?from=&to=&status=&currency=&limit=&starting_after=` |
| `/reconciliation/report` | `?from=&to=&currency=` |
| `/risk/alerts` | `?from=&to=&severity=low|medium|high&limit=` |

Response envelope:

```json
{"ok": true, "data": {...}}
{"ok": false, "error": {"code": "invalid_request", "message": "...", "details": {...}}}
```

## CLI

Every MCP tool is also available as a `stripe-reporting` subcommand, useful
for ad-hoc inspection, scripts, and CI checks. It uses the same backend
(demo or live Stripe) and the same Mongo audit log.

Run during development:

```bash
npm run dev:cli -- --help
npm run dev:cli -- balance-summary --currency USD
npm run dev:cli -- list-payments --limit 5 --pretty
npm run dev:cli -- get-payment pay_demo_6
npm run dev:cli -- customer-payments cus_demo_bob --from 2026-01-01
npm run dev:cli -- risk-alerts --severity high
```

After `npm run build` the CLI is at `dist/cliIndex.js` and exposed via the
`stripe-reporting` bin entry, so a global `npm install -g .` (or symlink via
`npm link`) gives you the binary on `$PATH`:

```bash
stripe-reporting capabilities --pretty
MCP_MODE=stripe STRIPE_SECRET_KEY=sk_live_... \
  stripe-reporting revenue-summary --from 2026-04-01 --to 2026-04-30
```

Commands:

| Command | Notes |
| --- | --- |
| `capabilities` | Prints mode, tool list, resource list |
| `balance-summary` | `--as-of`, `--currency` |
| `revenue-summary` | `--from`, `--to`, `--currency`, `--group-by`, `--include-refunds` |
| `list-payments` | `--from`, `--to`, `--status`, `--customer-id`, `--email`, `--currency`, `--limit`, `--starting-after` |
| `get-payment <payment_id>` | Charge ID or PaymentIntent ID |
| `list-customers` | `--email`, `--created-from`, `--created-to`, `--limit`, `--starting-after` |
| `customer-payments <customer_id>` | `--from`, `--to`, `--status`, `--limit`, `--starting-after` |
| `list-payouts` | `--from`, `--to`, `--status`, `--currency`, `--limit`, `--starting-after` |
| `reconciliation-report` | `--from`, `--to`, `--currency` |
| `risk-alerts` | `--from`, `--to`, `--severity`, `--limit` |

Global flags: `--pretty` (pretty-print JSON), `--help` / `-h` (use with a
command for command-specific help).

Output: stdout is the JSON result on success. On error the JSON error
envelope is written to **stderr** and the process exits with `2` (validation
errors) or `1` (everything else), so `if cli ...; then` works in scripts.

## Claude Code Setup

This repo includes a project-scoped MCP config in [.mcp.json](./.mcp.json):

```json
{
  "mcpServers": {
    "stripe-reporting": {
      "command": "node",
      "args": ["./node_modules/tsx/dist/cli.mjs", "src/index.ts"]
    }
  }
}
```

### Option A — project-scoped via `.mcp.json` (recommended)

The bundled `.mcp.json` is auto-discovered when you start Claude Code from
this repo. Steps:

1. `npm install` in the repo root.
2. Optionally export `MCP_MODE=stripe` and `STRIPE_SECRET_KEY=...`.
3. Run `claude` from this directory.
4. Approve the project-scoped `stripe-reporting` MCP server when prompted.
5. Verify with `/mcp` inside Claude Code — the server should appear connected.
6. Ask Claude to call reporting tools such as `get_revenue_summary` or
   `get_risk_alerts`.

### Option B — register it explicitly with `claude mcp add`

If you want it available outside this directory (e.g. user-scope across all
projects), register it once with the CLI:

```bash
# project scope (writes to ./.mcp.json)
claude mcp add stripe-reporting \
  --scope project \
  -- node ./node_modules/tsx/dist/cli.mjs src/index.ts

# or user scope (available in every project on this machine)
claude mcp add stripe-reporting \
  --scope user \
  --env MCP_MODE=demo \
  -- node /absolute/path/to/this/repo/node_modules/tsx/dist/cli.mjs \
        /absolute/path/to/this/repo/src/index.ts
```

Pass live-mode credentials via `--env`:

```bash
claude mcp add stripe-reporting \
  --scope user \
  --env MCP_MODE=stripe \
  --env STRIPE_SECRET_KEY=sk_test_... \
  -- node /absolute/path/to/this/repo/node_modules/tsx/dist/cli.mjs \
        /absolute/path/to/this/repo/src/index.ts
```

Manage the registration:

```bash
claude mcp list                       # confirm it shows up
claude mcp get stripe-reporting       # inspect the config
claude mcp remove stripe-reporting    # unregister
```

### Option C — point at a built artifact

After `npm run build`, you can register the compiled JS instead of running
through `tsx`:

```bash
claude mcp add stripe-reporting \
  --scope user \
  -- node /absolute/path/to/this/repo/dist/index.js
```

## Example Prompts

- "What is our current Stripe balance by currency?"
- "Summarize net revenue for the last 30 days by week."
- "Show me failed or disputed payments from the last 14 days."
- "List customers created this month and include their payment totals."
- "Give me a reconciliation summary for this month."
- "What risk alerts should finance review today?"

## Public Demo Positioning

If you publish this repo publicly, the honest framing is:

- **read-only Stripe reporting MCP**
- **demo-first, safe for agent visibility use cases**
- **live Stripe mode available, but not marketed as a full production finance system**

That is a strong, credible scope for a public demo.

## Validation Status

Validated locally with:

```bash
npm test
npm run self-check
npm run build
```

## License

MIT. See [LICENSE](./LICENSE).