Content
# lunchmoney-go
Go client and MCP server for the [Lunch Money v2 API](https://alpha.lunchmoney.dev/v2/).
- **Library**: import `github.com/Cidan/lunchmoney-go` to talk to the v2 API.
- **MCP server**: `go install github.com/Cidan/lunchmoney-go/cmd/lunchmoney-mcp@latest` exposes the API as MCP tools over stdio or Streamable HTTP.
The library exposes idiomatic Go method signatures backed by DTOs generated from the official OpenAPI spec via `oapi-codegen`.
## Auth modes
The client picks **exactly one** auth mode at construction time. Modes are mutually exclusive — there is **no precedence and no fallback**. Each call validates the request context against the configured mode and returns a typed error on mismatch.
| Mode | Constructor option | Required ctx values | Forbidden ctx values |
|---|---|---|---|
| Static | `WithStaticToken(s)` | — | `auth.Token`, `auth.TokenID` |
| Context | `WithContextAuth()` | `auth.Token` | `auth.TokenID` |
| OAuth2 stateful | `WithTokenSource(src)` | — | `auth.Token`, `auth.TokenID` |
| OAuth2 stateless | `WithTokenStore(cfg, store)` | `auth.TokenID` | `auth.Token` |
Errors:
- `ErrNoAuthMode` — `NewClient` called with no auth option
- `ErrConflictingAuthModes` — `NewClient` called with more than one auth option
- `ErrAuthModeMismatch` — context carries values inconsistent with the configured mode
- `ErrMissingContextToken` — ContextAuth client was called without `auth.WithToken`
- `ErrMissingContextTokenID` — TokenStore client was called without `auth.WithTokenID`
- `ErrOAuth2NotImplemented` — placeholder for the OAuth2 stub (see below)
All errors support `errors.Is`.
### Mode 1: static token
```go
c, _ := lunchmoney.NewClient(lunchmoney.WithStaticToken(os.Getenv("LM_TOKEN")))
me, err := c.Me.Get(ctx)
```
### Mode 2: token from `context.Context`
```go
import (
lunchmoney "github.com/Cidan/lunchmoney-go"
"github.com/Cidan/lunchmoney-go/auth"
)
c, _ := lunchmoney.NewClient(lunchmoney.WithContextAuth())
ctx = auth.WithToken(ctx, req.Header.Get("X-LM-Token"))
txns, err := c.Transactions.List(ctx, nil)
```
The same client instance can serve many tenants concurrently — the token is read from `ctx` per request.
### Mode 3: OAuth2 stateful (one client == one token)
```go
import "github.com/Cidan/lunchmoney-go/oauth2lm"
cfg := &oauth2lm.Config{
ClientID: "abc",
ClientSecret: "shh",
RedirectURL: "https://app.example.com/cb",
Scopes: []string{"read", "write"},
}
src := cfg.TokenSource(ctx, &oauth2lm.Token{AccessToken: "..."})
c, _ := lunchmoney.NewClient(lunchmoney.WithTokenSource(src))
```
### Mode 4: OAuth2 stateless (TokenStore + ID from context)
```go
type pgStore struct{ db *sql.DB }
func (s *pgStore) Get(ctx context.Context, id string) (*oauth2lm.Token, error) { /* ... */ }
func (s *pgStore) Put(ctx context.Context, id string, t *oauth2lm.Token) error { /* ... */ } // refresh writeback
cfg := &oauth2lm.Config{ /* ... */ }
c, _ := lunchmoney.NewClient(lunchmoney.WithTokenStore(cfg, &pgStore{db: db}))
ctx = auth.WithTokenID(ctx, currentUserID)
txns, err := c.Transactions.List(ctx, nil)
```
### OAuth2 status
OAuth2 is **stubbed**. The `oauth2lm` package exists with the full type and interface surface so callers can wire OAuth2-mode clients today, but the operational helpers (`Config.Exchange`, `TokenSource.Token`, `Config.AuthCodeURL`) all return `ErrOAuth2NotImplemented`. The wiring through the lunchmoney client itself (mode validation, ctx checks, store fetch / source call) is real and exercised by the test suite — only the credential-issuing/refreshing logic is missing.
## Resources
Services on the client (Stripe-style):
- `c.Me`
- `c.Summary`
- `c.Categories` — `List`, `Get`, `Create`, `Update`, `Delete`
- `c.ManualAccounts` — `List`, `Get`, `Create`, `Update`, `Delete`
- `c.PlaidAccounts` — `List`, `Get`, `Fetch`
- `c.Transactions` — `List`, `Get`, `Create`, `UpdateTransaction`, `BulkUpdate`, `Delete`, `BulkDelete`, `Group`, `Ungroup`, `Split`, `Unsplit`, `AttachFile`, `AttachmentURL`, `DeleteAttachment`
- `c.Tags` — `List`, `Get`, `Create`, `Update`, `Delete`
- `c.RecurringItems` — `List`, `Get`
- `c.Budgets` — `Settings`, `Upsert`, `Delete`
Use `lunchmoney.MockBaseURL` (or the convenience `WithMockServer()` option) to point at the static mock server, which accepts any bearer token of ≥11 characters.
## MCP server
```sh
# stdio (default; for desktop MCP clients)
LUNCHMONEY_TOKEN=lm_... lunchmoney-mcp
# explicit token
lunchmoney-mcp --token lm_...
# Streamable HTTP, one shared static token
lunchmoney-mcp --transport http --addr :8080 --token lm_...
# Streamable HTTP, per-request token from a header
lunchmoney-mcp --transport http --addr :8080 --auth header:X-LM-Token
# Run against the static mock server (for local dev)
lunchmoney-mcp --transport stdio --token TESTTOKEN1234 --base-url mock
```
Tools registered on the server — **100% v2 endpoint coverage** (every read + write endpoint in `docs/lunchmoney.json` has a matching MCP tool):
- `lm_me_get`, `lm_summary_get`
- Categories — `lm_categories_list`, `lm_categories_get`, `lm_categories_create`, `lm_categories_update`, `lm_categories_delete`
- Manual accounts — `lm_manual_accounts_list`, `lm_manual_accounts_get`, `lm_manual_accounts_create`, `lm_manual_accounts_update`, `lm_manual_accounts_delete`
- Plaid accounts — `lm_plaid_accounts_list`, `lm_plaid_accounts_get`, `lm_plaid_accounts_fetch`
- Tags — `lm_tags_list`, `lm_tags_get`, `lm_tags_create`, `lm_tags_update`, `lm_tags_delete`
- Recurring — `lm_recurring_list`, `lm_recurring_get`
- Budgets — `lm_budgets_settings_get`, `lm_budgets_upsert`, `lm_budgets_delete`
- Transactions — `lm_transactions_list`, `lm_transactions_get`, `lm_transactions_create`, `lm_transactions_update`, `lm_transactions_delete`, `lm_transactions_bulk_update`, `lm_transactions_bulk_delete`, `lm_transactions_group`, `lm_transactions_ungroup`, `lm_transactions_split`, `lm_transactions_unsplit`, `lm_transactions_attachment_upload`, `lm_transactions_attachment_url`, `lm_transactions_attachment_delete`
Write tools that target endpoints with `oneOf`-encoded request fields (manual account `balance`, budget / transaction `amount`, split child `amount`) accept a free-form `body` map and route through `Client.RawJSON`; the resulting payload still goes through the auth-mode validation in `Client.do`. Tools whose body is fully typed (delete with options, bulk delete, plaid fetch, attachment URL/delete, ungroup/unsplit) call the corresponding typed method on the client.
The attachment upload tool (`lm_transactions_attachment_upload`) accepts the file as a base64-encoded `content_b64` string plus `file_name` and `content_type`; the server decodes and forwards as `multipart/form-data`.
## Code generation
Generated DTOs live in `internal/lmtypes` and are produced from `docs/lunchmoney.json` by `oapi-codegen`, pinned via the Go 1.24+ `tool` directive in `go.mod`.
To regenerate:
```sh
go generate ./...
```
The public surface in the root package re-exports the DTOs callers need (e.g. `lunchmoney.Transaction`, `lunchmoney.Category`); the generated package itself is private and not importable from outside the module.
## Testing
```sh
# Unit tests + MCP smoke tests (no network)
go test ./...
# Read-only integration tests against the live mock server
go test -tags=integration ./...
# Full CRUD integration tests (slower; mock has a per-token rate limit)
go test -tags=integration_crud -p 1 ./...
```
The integration suite uses bearer token `TESTTOKEN1234` (any ≥11-character string works against the mock at `https://mock.lunchmoney.dev/v2`). The `integration_crud` suite adds Create / Update / Delete coverage that validates request payload shapes against the mock's OpenAPI validator. It's gated separately because the mock enforces a token-bucket rate limit (~10 reqs / 40s) and the CRUD suite issues enough calls to flake under contention; running it on its own with `-p 1` keeps it reliable.
> **Note**: a few typed Create methods (`ManualAccounts.Create`, `Transactions.Create`, `Budgets.Upsert`) include fields whose schema uses `oneOf` — `oapi-codegen` wraps these in a struct with a private union, so they can't be populated from the public surface. For now, route those creates through `Client.RawJSON` (the integration suite demonstrates this); a future pass can add ergonomic constructor helpers.
## License
Apache 2.0 (matches the upstream OpenAPI spec).