# enty-mcp An MCP server for [Enty.io](https://enty.io) — invoices, bank transactions, accounting deadlines, and contracts, exposed as tools your AI agent can call. Enty has no public API. This server drives the same GraphQL endpoint the Enty web app uses, authenticated with your browser session token. That comes with caveats you should read before using it: > **Unofficial.** This project is not affiliated with or endorsed by Enty. It talks to > an undocumented internal API that can change or break at any time, and using it may > not be covered by Enty's terms of service. It reads your real company data — there is > no sandbox. Two tools also write to it (catalog items only); nothing deletes. ## Tools | Tool | What it returns | |---|---| | `enty_get_me` | Logged-in user + active company | | `enty_list_companies` | Companies on the account | | `enty_list_invoices` | Invoices with status filter, totals, per-status counts | | `enty_list_counterparties` | Clients/providers with tax details | | `enty_list_items` | Catalog items (invoice line items) | | `enty_list_item_units` | Units of measure an item may use | | `enty_list_transaction_documents` | Documents already attached to a transaction | | `enty_list_invoice_payment_candidates` | Transactions that could be an invoice's payment | | `enty_list_bank_accounts` | Bank accounts with IBAN and connection status | | `enty_list_transactions` | Transactions with account/date/direction/text filters | | `enty_get_transaction` | One transaction with note and category | | `enty_get_balance` | Combined balance of chosen accounts | | `enty_get_transactions_statistics` | Document-matching stats for a date range | | `enty_list_categories` | Transaction categorization tree | | `enty_list_accounting_periods` | Accounting periods | | `enty_get_period_deadlines` | Next tax/reporting deadline for a period | | `enty_get_potential_tax` | Estimated tax for a period, by rate (EUR) | | `enty_get_period_issues` | Open bookkeeping issues for a period | | `enty_list_contracts` | Contracts with e-sign lifecycle status | | `enty_list_deals` | Deals with status and counterparty | | `enty_get_deals_stats` | Expected vs received deal amounts | ### Write tools | Tool | What it does | |---|---| | `enty_create_item` | Create a catalog item (product/service) | | `enty_update_item` | Update a catalog item's name, price, currency, unit, or description | | `enty_create_deal` | Create a deal for a counterparty | | `enty_create_invoice` | Create a draft invoice with line items | | `enty_mark_invoice_paid` | Mark an invoice paid | | `enty_link_transaction_to_invoice` | Record a bank transaction as an invoice's payment | | `enty_attach_document_to_transaction` | Upload a local file and attach it to a transaction | These write to your live company. Two risk levels are worth separating. Catalog items and deals are reference data. Creating or editing one does not alter invoices that already reference it. The last three touch bookkeeping. Linking a transaction to an invoice and attaching documents change records your accountant works from, and a wrong link reads as a reconciliation error. None of them can be undone here, so check ids before calling. `enty_create_invoice` leaves a **draft**. Nothing is sent to the client and no PDF is generated; review and issue it in the Enty web app. It takes four calls, because Enty builds an invoice that way: create a blank draft, patch the header, add the lines, store the totals. If a later step fails the draft survives, and the error carries its id so you can finish or discard it by hand. `enty_link_transaction_to_invoice` needs the invoice to have a generated document, since the link is between that document and the transaction. Enty has no separate payment record. Open a fresh invoice once in the web app if the tool reports it has none. `enty_attach_document_to_transaction` is the one path that is not GraphQL. Bytes go to Enty's file-storage service as multipart form data, the returned file is registered as an accounting document, and only then is it linked to the transaction. **Nothing in this server deletes.** Enty's API has 233 mutations, 19 of them deletions (`deleteItemsAtOrganization`, `DeleteTransactions`, `DeleteUserCompanies`, and so on). None are exposed, and the GraphQL client refuses to send any mutation whose operation name is not in an explicit allowlist (`ALLOWED_MUTATIONS` in `src/enty_mcp/graphql.py`), so a blocked write never leaves the process. Deleting anything is a manual action in the Enty web app, on purpose. `enty_update_item` merges: pass only the fields you want changed. Enty's update replaces the whole item, so the tool reads the item's current values first and sends them back alongside your changes. Without that, updating one field would blank the rest. The rest of the mutation surface is mapped (`spec/graphql/bundle-operations/`) but unexposed. Adding a write means adding its name to the allowlist, which is the review step. ## Authenticating Two options. Pick one. **Email and password** (`ENTY_EMAIL`, `ENTY_PASSWORD`). The server logs in for you, keeps the session token in memory, and logs in again by itself when the session expires. Nothing to do when a token dies, which is what you want for a server meant to run unattended. Enty issues no refresh token, so renewing really is a fresh login. **A session token** (`ENTY_AUTH_TOKEN`). Log into [app.enty.io](https://app.enty.io), open DevTools → Network, click any request to `/api/`, and copy the value of the `enty-auth` request header. This expires, and without credentials the server cannot renew it, so you repeat the steps whenever tools start failing. Set both and the token is used first, falling back to the credentials once it expires. That skips a login on startup while still surviving expiry. Either way the secret grants full access to your Enty account, so treat it like the password it effectively is. Credentials and tokens are held as pydantic `SecretStr`, kept out of the client's shared headers, sent only to your configured `ENTY_BASE_URL`, and never written to logs. The account email is logged at INFO when a login happens, so you can tell which account a running server is using. Accounts with multi-factor authentication cannot use email and password. Enty answers those logins with no session id, and the server says so rather than failing obscurely. Use a token for those. ## Running ### With uv ```bash git clone https://github.com/appsome/enty-mcp-server cd enty-mcp-server cp .env.example .env # paste your token uv sync uv run enty-mcp # streamable HTTP on :8001/mcp uv run enty-mcp --transport stdio # for stdio clients ``` ### With Docker Compose ```bash cp .env.example .env # fill in ENTY_EMAIL and ENTY_PASSWORD docker compose up -d # server: http://localhost:8001/mcp health: http://localhost:8001/health ``` The compose file builds from source. To run a published image instead: ```bash docker run -d -p 8001:8001 \ -e ENTY_EMAIL=you@example.com -e ENTY_PASSWORD=... \ ghcr.io/appsome/enty-mcp-server:latest ``` Images are built for linux/amd64 and linux/arm64 and pushed to GitHub Container Registry on every push to `main`, tagged `latest` and with the commit sha. Pushing a `v*` tag adds the matching semver tags. The container exits immediately with a clear error if neither credentials nor a token are set. ### Configuration | Env var | Default | | |---|---|---| | `ENTY_EMAIL` / `ENTY_PASSWORD` | — | credentials; server logs in and renews itself | | `ENTY_AUTH_TOKEN` | — | session token; alternative to the above | | `ENTY_BASE_URL` | `https://app.enty.io` | | | `ENTY_LOCALE` | `en` | | | `ENTY_TIMEOUT_S` | `30` | | | `MCP_HOST` / `MCP_PORT` | `0.0.0.0` / `8001` | HTTP transports | | `LOG_LEVEL` | `INFO` | | ## Connecting a client **OpenHands** (docker compose): use `docker-compose.override.example.yml` to add the service to your stack, then point OpenHands at `http://enty-mcp:8001/mcp` — see `openhands/mcp.json` and `openhands/config.toml.snippet`. **Claude Code**: ```bash claude mcp add --transport http enty http://localhost:8001/mcp ``` **Claude Desktop** (stdio): ```json { "mcpServers": { "enty": { "command": "uv", "args": ["run", "--directory", "/path/to/enty-mcp-server", "enty-mcp", "--transport", "stdio"], "env": { "ENTY_EMAIL": "you@example.com", "ENTY_PASSWORD": "..." } } } } ``` ## How the API mapping was done Enty's GraphQL endpoint has introspection disabled, so the schema was reconstructed from two sources: a HAR capture of the web app (56 operations with response shapes, `spec/graphql/operations/`) and the app's JS bundles, which contain every GraphQL document the frontend can send (434 operations, `spec/graphql/bundle-operations/`). `spec/graphql/NOTES.md` documents the conventions and the remaining gaps. The extraction scripts in `scripts/` are repeatable when the app changes. ## Development ```bash uv sync uv run pytest # respx-mocked, no network uv run ruff check src tests scripts uv run pyright ``` Tests never touch the real API. Fixture data is synthetic, shaped like recorded responses. ## License MIT