Development
This page is for people working on crunch-mcp itself. Start with CONTRIBUTING.md for the workflow, and use this page for how the code fits together.
You need Go 1.25 or later, and Docker for building the image.
git clone https://github.com/mattoddie/crunch-mcp.gitcd crunch-mcpmake test # runs all tests against a mock Crunch API and OAuth server| Target | What it does |
|---|---|
make build |
Build a static binary to bin/crunch-mcp. Pass VERSION=1.2.3 to stamp a version. |
make test |
go test ./... |
make lint |
Fail if any file needs gofmt, then run go vet ./... |
make docs |
Regenerate docs/tools.md from the tool definitions |
make docs-check |
Fail if docs/tools.md is out of date (CI runs this) |
make image |
Build the Docker image as crunch-mcp:$(VERSION) |
The project deliberately has only two direct dependencies: the official MCP Go SDK, github.com/modelcontextprotocol/go-sdk, and github.com/google/jsonschema-go, which the SDK uses for input schemas. Everything else is the standard library. Please discuss before adding a dependency.
Project layout
Section titled “Project layout”cmd/├── crunch-mcp/ # the binary: serve, login, status, healthcheck and version commands└── gen-tool-docs/ # writes docs/tools.md from the registered tools and promptsinternal/├── config/ # environment variables → typed Config (validation and default scopes live here)├── crunch/│ ├── auth.go # OAuth 2.0: PKCE login, the token file, serialised refreshes of rotating tokens│ ├── client.go # Client: bearer auth, refresh on 401, retries on 429/503, errors with hints│ ├── format.go # helpers to read API objects and build compact summaries│ └── crunchtest/ # an in-process fake Crunch API and OAuth server for tests├── httpserver/ # stateless Streamable HTTP, bearer auth, Host allow-list, /health└── tools/ ├── tools.go # the registration framework: Def, Gate, add[In](), Money, Date, schemas ├── server.go # NewServer(): server instructions, registers every toolset and the prompts ├── helpers.go # paging, search and limit, read-modify-write merging, attachments, downloads ├── prompts.go # the built-in MCP prompts └── <toolset>.go # one file per toolset: account, reference, banking, clients, suppliers, …How a request flows
Section titled “How a request flows”- An MCP client calls a tool, for example
crunch_update_client {client_id: 1, email: "new@example.com"}. - The MCP SDK validates the arguments against the tool’s JSON Schema, which was inferred from its Go input struct, and decodes them into that struct.
- The handler registered by
add()runs. For an update, it fetches the current record, merges in only what was passed, and sends the result back. crunch.Clientgets an access token fromAuth(refreshing it first if it’s about to expire), sends the request, and unwraps the JSON. On401it refreshes once and retries. On429or503it waits and retries up to three times. Other failures become acrunch.Errorwith Crunch’s message and a hint for401,403and500.- The handler returns a compact summary (built with
crunch/format.go), or aResultfor files. add()serialises the value to indented JSON text. A returned error becomesisError: truewith the message, so the model can see what went wrong and adjust.
Auth is where the care goes. Crunch rotates refresh tokens and revokes the login if an old one is reused, so Refresh() holds a mutex, re-checks whether another caller has already refreshed, and accept() saves the new tokens to disk before handing out the new access token.
Adding a tool
Section titled “Adding a tool”Tools are defined with the generic add[In]() in the file for their toolset. Here’s a complete example from clients.go:
type updateClientArgs struct { ClientID int64 `json:"client_id" jsonschema:"Client (customer) ID (see crunch_list_clients)"` Name string `json:"name,omitempty" jsonschema:"New name"` BillingAddress *Address `json:"billing_address,omitempty" jsonschema:"New billing address (replaces the whole address)"` clientFields}
add(c, Def{ Toolset: "clients", Name: "crunch_update_client", Title: "Update client", Description: "Change a client's details. Only the fields you pass change; the rest are kept (a new billing_address replaces the whole address).", Gate: Write, Idempotent: true,}, func(ctx context.Context, a updateClientArgs) (any, error) { p := path("/clients/%d", a.ClientID) var current Obj if err := cl.Get(ctx, p, nil, ¤t); err != nil { return nil, err } merged := deepMerge(current, a.body(a.Name)) if a.BillingAddress != nil { merged["billingAddress"] = a.BillingAddress.body() } var out any return out, cl.Put(ctx, p, nil, merge(merged, a.Extra), &out)})The input struct
Section titled “The input struct”The input struct is the tool’s argument schema:
- The
jsontag names the argument. Usesnake_case. omitemptymakes it optional. Without it, the argument is required.- The
jsonschematag is the argument’s description, which the model reads. Every argument needs one, and a test checks this. - Use pointers (
*int,*bool,*string) for optional values where zero means something, so “not given” and “set to zero” differ. Moneytakes an amount as a number (120) or a decimal string ("120.00") and sends it to Crunch as an exact JSON number. Use it for every amount.Dateis aYYYY-MM-DDstring. Any string argument nameddate,*_dateordate_*gets a date pattern in its schema automatically.- Embedded structs, such as
PageArgsorclientFieldsabove, add their fields to the tool. - Tools without arguments take
NoArgs.
The Def
Section titled “The Def”| Field | Effect |
|---|---|
Toolset |
Required. The tool is only registered when this toolset is enabled. |
Name, Title, Description |
The tool’s name (crunch_<verb>_<thing>), a short human title, and the description the model reads. |
Gate |
Read (the default, always registered), Write (needs CRUNCH_ALLOW_WRITES), Delete (also needs CRUNCH_ALLOW_DELETES) or Email (also needs CRUNCH_ALLOW_EMAIL). |
ReadOnly |
Overrides the readOnlyHint annotation, which defaults to Gate == Read. |
Destructive |
Overrides the destructiveHint annotation for non-read-only tools, which defaults to Gate == Delete. Set it to true for writes that can’t be undone, such as issuing an invoice. |
Idempotent |
Repeating the call with the same arguments has no further effect. Sets idempotentHint. |
OpenWorld |
The tool reaches outside Crunch, such as emailing a customer. Sets openWorldHint. |
Enums |
Restricts string arguments to fixed values, keyed by argument name, for example {"for": {"expenses", "invoices"}}. |
Helpers
Section titled “Helpers”| Helper | Use it for |
|---|---|
PageArgs and fetchPaged() |
List tools over Crunch’s paged endpoints: page, size, sort, and all with max_items. |
filterAndLimit() |
List tools over endpoints that return everything: a search filter and a limit, with consistent {total, returned, items} results. |
deepMerge() |
Read-modify-write updates: merge the changes into the record fetched from Crunch. |
set() |
Build a request body, skipping empty strings and nil pointers. |
merge() |
Apply extra or changes (raw Crunch fields) last, so they override typed arguments. |
ref() |
Turn an optional ID into Crunch’s {"id": …} reference. |
Attachment and attachmentsBody() |
Uploads from base64, a data: URI or a file path, sent as the data: URIs Crunch requires. |
fileResult() and saveFile() |
Downloads: images inline, other files as embedded resources, and save_to without overwriting. |
Checklist for a new tool
Section titled “Checklist for a new tool”- Put it in the right
internal/tools/<toolset>.go. If it needs a new toolset, add it toToolsetsininternal/config/config.go, describe it intoolsetInfoincmd/gen-tool-docs/main.go, and register it inNewServer(). - Set its
Gatecorrectly, and setDestructivedeliberately. - For updates, fetch the current record and merge, so nothing you didn’t mean to change is sent back missing. Crunch deletes invoice payments and attachments that are left out of a
PUT. - Return a compact summary and offer
raw: truefor the full object. Never return tokens. - Write descriptions for the model: what the tool does, when to use it, which tool supplies each ID (“see crunch_list_clients”), units, side effects (“this is final”), and Crunch’s own limits.
- Add tests in
internal/tools/<toolset>_test.gousing the mock. - Run
make docsand commit the updateddocs/tools.md. If it’s a notable feature, update the README table and CHANGELOG.
Tool design guidelines
Section titled “Tool design guidelines”- Fewer, broader tools beat many narrow ones. One update tool with optional fields is easier for a model than a tool per field.
- Never surprise. Updates only change what was asked. Destructive actions are flagged. Deletes and email are behind their own switches.
- Point the model at valid values. Crunch is strict about expense types, VAT types and payment methods. Descriptions should name the
referencetool that lists them. - Errors should teach. Return messages that tell the model, or the user, how to fix the call: “give at least one item”.
crunch.Erroralready adds hints for401,403and500. - Respect Crunch’s own rules. Don’t work around a
403, a closed period or a reconciled payment; report it.
Testing
Section titled “Testing”Tests use Go’s testing package against crunchtest.Mock (internal/crunch/crunchtest), an httptest server that imitates both the Crunch API (under /v1) and its OAuth server (under /oauth):
- Every API request needs a valid bearer token.
crunchtest.AccessTokenandcrunchtest.RefreshTokenare valid from the start. - The OAuth server implements the authorisation code flow with PKCE and rotating refresh tokens: each refresh token works once, just like Crunch.
IssueCode(challenge)simulates the user clicking Allow, andRevokeAccess()expires every access token. On(method, pattern, handler)adds a route. The pattern is a regular expression matched against the whole path after/v1, and later routes win.crunchtest.JSON(v)is a handler that returnsv. Return[]bytefor raw file content.- Unmatched
GETs return404. UnmatchedPOSTandPUTrequests echo their body back with anid, and unmatchedDELETEs succeed, so simple write tools can be tested by inspecting what was sent. Requests()andLast(method)show what the server sent, with JSON bodies decoded.
newEnv(t) in helpers_test.go starts the mock, a client pointed at it and an in-memory MCP session with every tool enabled. env.obj() and env.call() run a tool and decode its JSON (failing on a tool error), and env.callErr() returns a tool’s error text. A typical test:
func TestUpdateClientKeepsOtherFields(t *testing.T) { env := newEnv(t) env.mock.On("GET", "/clients/1", crunchtest.JSON(Obj{ "id": 1, "name": "Example Ltd", "email": "old@example.com", "billingAddress": Obj{"line1": "1 Road", "line2": "Town", "country": "GB"}, })) env.call("crunch_update_client", map[string]any{"client_id": 1, "email": "new@example.com"}) got := asJSON(t, env.mock.Last("PUT").Body) want := `{"billingAddress":{"country":"GB","line1":"1 Road","line2":"Town"},"email":"new@example.com","id":1,"name":"Example Ltd"}` if got != want { t.Errorf("update client sent %s", got) }}To test that a tool is gated, pass different options: newEnv(t, opts) or connect(t, env.client, opts) with a config.Tools, and check the tool isn’t listed with toolNames(). TestGatesControlWhichToolsExist already checks every tool against its Gate, and TestSchemasDescribeEveryArgument checks that every argument has a description.
Run one package or test with go test ./internal/tools -run TestUpdateClient -v.
Running against a real Crunch account
Section titled “Running against a real Crunch account”There’s no Crunch sandbox, so test against a free Crunch account you’ve set up for the purpose, never your real books. Your API application needs to be approved first. See Getting started.
With the MCP Inspector:
make buildexport CRUNCH_CLIENT_ID=your-client-id CRUNCH_TOKEN_FILE=$PWD/tokens.json./bin/crunch-mcp loginnpx @modelcontextprotocol/inspector ./bin/crunch-mcpOr build the image and point Claude Code at it:
make imagedocker run --rm -it -p 8976:8976 -v crunch-dev:/data --env-file .env crunch-mcp:0.0.0-dev logindocker run --rm -p 8080:8080 -v crunch-dev:/data --env-file .env crunch-mcp:0.0.0-devclaude mcp add --transport http crunch-dev http://localhost:8080/mcp --header "Authorization: Bearer $MCP_AUTH_TOKEN"tokens.json and .env are in .gitignore. Keep it that way, and never paste real tokens, client secrets or financial data into issues, tests or fixtures.
Docs that are generated
Section titled “Docs that are generated”docs/tools.md is generated by cmd/gen-tool-docs. The program builds the server with every tool enabled, records each tool’s toolset and gate as it’s registered, reads the tools and prompts back over MCP, and writes the reference. Don’t edit docs/tools.md by hand. Change the tool’s title, description or jsonschema tags and run make docs. CI runs make docs-check and fails if the committed file is stale.
The website
Section titled “The website”The site at crunch-mcp.mattoddie.dev lives in website/. It has a hand-built landing page plus these docs rendered with Astro Starlight. It has its own package.json, so its dependencies never reach the server or the Docker image.
The Markdown in docs/ is the single source: the site copies it in at build time. It turns each page’s first # Heading into the page title, drops hand-written “Contents” lists, rewrites links between .md files to site URLs and converts GitHub alerts (> [!NOTE]) to Starlight asides. So write docs as normal GitHub Markdown, start each page with a # Title, and link between pages with relative .md links.
To preview the site locally:
cd websitenpm cinpm run dev