# MCP server

> Use Alya Books from Claude, Cursor, ChatGPT or any MCP client: invoices, incoming documents, the ERP intake, customers, products, webhooks and VAT as tools.

Source: /docs/guides/mcp-server

Alya Books exposes its API as a [Model Context Protocol](https://modelcontextprotocol.io) server. Every tool maps onto one REST route, so validation, permissions and behaviour are identical to the API.

## Endpoint

```text
POST https://api.alyabooks.ai/api/v1/mcp
```

Transport: **Streamable HTTP**, stateless. Requests are JSON-RPC 2.0 messages (single or batched); responses are JSON. The server never opens a server-sent events stream, so `GET /mcp` answers `405`.

Headers on every request:

```http
Content-Type: application/json
Accept: application/json
X-API-KEY: sk_live_…
X-Organization-Id: <client id>   (optional, CA firms)
```

Authentication is the same API key as the REST API; a tool is only callable when the key has its scope (`invoices:write`, `incoming:read`, `webhooks:manage`, …). Test keys make the assistant work on test documents.

## Connect from claude.ai or ChatGPT (sign-in)

Chat apps that add MCP servers as **custom connectors** cannot send an API key, so the endpoint also supports OAuth 2.1, the way the MCP specification describes:

1. In Claude (Settings → Connectors → Add custom connector) or ChatGPT (connectors in developer mode), enter the MCP URL: `https://<your API host>/functions/v1/api-v1/mcp` (`https://api.alyabooks.ai/api/v1/mcp` once the API domain is live).
2. The app registers itself and opens Alya Books's sign-in. After signing in, you choose the **organisation**, **Test or Live** and the **permissions**, then click **Allow**.
3. The app receives a one-hour access token and a refresh token; it refreshes on its own. Revoke it any time under **API access → API keys → Connected apps**.

What the server publishes, for client developers:

| | |
|---|---|
| Protected resource metadata | `{API}/.well-known/oauth-protected-resource` (also linked from the `WWW-Authenticate` header of every 401 from `/mcp`) |
| Authorization server metadata | `{API}/.well-known/openid-configuration` and `{API}/.well-known/oauth-authorization-server`; issuer `{API}` |
| Registration | `POST {API}/oauth/register` (RFC 7591), public clients only (`token_endpoint_auth_method: none`), https redirect URIs or http on localhost |
| Authorization | the Alya Books app's `/oauth/authorize`; authorization code with PKCE `S256` required |
| Token | `POST {API}/oauth/token`: `authorization_code` and `refresh_token`; refresh tokens rotate on every use; codes are single-use and a reused code revokes what it produced |
| Revocation | `POST {API}/oauth/revoke` (RFC 7009) |

Access tokens (`oat_…`) work on `/mcp` only; the REST API keeps using API keys. A token belongs to one organisation, so `X-Organization-Id` cannot switch it. Clients that only look for discovery documents at the host's root, rather than under the API's path, need the API domain to forward `/.well-known/…` to the API.

## Connect a client with an API key

```json
{
  "mcpServers": {
    "alyabooks": {
      "type": "http",
      "url": "https://api.alyabooks.ai/api/v1/mcp",
      "headers": { "X-API-KEY": "sk_live_…" }
    }
  }
}
```
```json
{
  "mcpServers": {
    "alyabooks": {
      "url": "https://api.alyabooks.ai/api/v1/mcp",
      "headers": { "X-API-KEY": "sk_live_…" }
    }
  }
}
```
```ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(new URL("https://api.alyabooks.ai/api/v1/mcp"), {
  requestInit: { headers: { "X-API-KEY": process.env.ALYABOOKS_API_KEY! } },
});
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);
const { tools } = await client.listTools();
const result = await client.callTool({ name: "list_invoices", arguments: { status: "accepted", perPage: 5 } });
```
```bash
curl -X POST https://api.alyabooks.ai/api/v1/mcp -H "X-API-KEY: $KEY" -H "Content-Type: application/json" \
  -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_account", "arguments": {} } }'
```

Claude Code: `claude mcp add --transport http alyabooks https://api.alyabooks.ai/api/v1/mcp --header "X-API-KEY: sk_live_…"`.

## Tools

149 tools in 17 groups: 72 only read, the rest change data. Tool descriptions state their scope; `tools/list` returns full JSON Schemas for the arguments and the annotations below.

| Group | Tools |
| --- | --- |
| Invoice creation | `create_tax_invoice`, `create_credit_note`, `create_debit_note`, `create_invoices_batch`, `replace_draft_invoice` |
| Invoice management | `list_invoices`, `get_invoice`, `get_invoice_status`, `get_invoice_validation`, `validate_invoice`, `submit_invoice`, `send_invoice`, `download_invoice`, `delete_draft_invoice`, `update_invoice_housekeeping`, `list_design_templates`, `list_invoice_payments`, `record_invoice_payment` |
| Invoice numbers | `get_next_invoice_number`, `verify_invoice_number`, `get_invoice_number_settings`, `update_invoice_number_settings` |
| Incoming invoices | `simulate_incoming_invoice`, `import_incoming_invoice`, `list_incoming_imports`, `list_incoming_invoices`, `get_import`, `get_import_status`, `get_import_validation`, `get_import_content`, `get_import_summary`, `get_import_line_items`, `get_import_seller`, `get_import_buyer`, `get_import_taxes`, `get_import_payment_terms`, `get_import_payment_means`, `get_import_delivery`, `get_import_attachments`, `get_import_source`, `review_incoming_invoice`, `update_incoming_invoice`, `record_incoming_payment`, `delete_import` |
| Customers | `list_customers`, `get_customer`, `create_customer`, `update_customer`, `delete_customer`, `get_next_customer_number`, `verify_customer_number`, `lookup_peppol_participant`, `verify_customer_peppol`, `list_contact_persons`, `add_contact_person`, `update_contact_person`, `delete_contact_person`, `list_contact_addresses`, `add_contact_address`, `update_contact_address`, `delete_contact_address`, `import_customers` |
| Products | `list_products`, `get_product`, `create_product`, `update_product`, `delete_product`, `check_product_code`, `get_product_price`, `list_bundles`, `get_bundle`, `create_bundle`, `update_bundle`, `delete_bundle`, `expand_bundle`, `list_price_lists`, `get_price_list`, `create_price_list`, `update_price_list`, `delete_price_list`, `import_products` |
| Webhooks | `list_webhooks`, `list_webhook_events`, `create_webhook`, `update_webhook`, `delete_webhook`, `test_webhook`, `list_webhook_deliveries`, `retry_webhook_delivery` |
| Custom fields | `list_custom_fields`, `create_custom_field`, `update_custom_field`, `delete_custom_field`, `list_field_templates`, `create_field_template`, `update_field_template`, `activate_field_template`, `delete_field_template` |
| Projects | `list_projects`, `get_project`, `create_project`, `update_project`, `delete_project`, `list_project_documents` |
| Recurring invoices | `list_recurring_invoices`, `get_recurring_invoice`, `create_recurring_invoice`, `update_recurring_invoice`, `activate_recurring_invoice`, `pause_recurring_invoice`, `cancel_recurring_invoice`, `run_recurring_invoice_now`, `list_recurring_runs`, `preview_recurring_invoice`, `make_invoice_recurring`, `delete_recurring_invoice` |
| Settings | `list_email_templates`, `save_email_template`, `get_options` |
| VAT | `list_tax_rates`, `create_tax_rate`, `update_tax_rate`, `list_vat_periods`, `get_vat_worksheet`, `list_vat_returns`, `save_vat_return`, `reopen_vat_return`, `check_period_date`, `export_accounting` |
| ERP intake | `dry_run_invoice`, `dry_run_erp_message`, `send_erp_message`, `list_erp_messages`, `get_erp_message`, `replay_erp_message`, `list_erp_connectors`, `update_erp_connector` |
| Integrations | `list_connectors`, `sync_connector` |
| Payments | `create_payment_link`, `list_payment_links`, `cancel_payment_link` |
| Bank feeds | `list_bank_transactions`, `match_bank_transaction`, `ignore_bank_transaction`, `import_bank_statement`, `sync_bank_feeds` |
| Account | `get_account`, `list_managed_organizations` |

**Marked destructive** (`destructiveHint`, so clients ask before running them): `submit_invoice`, `send_invoice`, `delete_draft_invoice`, `delete_import`, `delete_customer`, `delete_contact_person`, `delete_contact_address`, `delete_product`, `delete_bundle`, `delete_price_list`, `delete_webhook`, `delete_custom_field`, `delete_field_template`, `delete_project`, `delete_recurring_invoice`, `reopen_vat_return`, `send_erp_message`, `replay_erp_message`. Submitting, sending and replaying reach the FTA or a buyer and cannot be undone; deletes remove data. The dry runs (`dry_run_invoice`, `dry_run_erp_message`) are marked read-only: they store nothing.

**Test or live.** Every `tools/call` result carries `_meta: { "einvoicing/environment": "test" }` or `"live"`, from the key in use. With a test key nothing reaches the live service provider.

**Not available to assistants**, on purpose: API keys, team members, the service-provider connection, the company profile, and the adviser's sign-off on an ERP charge map. Those are set in the app by the people responsible.

Tool results contain the API response as text and as `structuredContent`; API errors are returned as tool results with `isError: true` and the usual `{ code, statusCode, message, extra }` envelope, with each validation error's path, explanation and fix, so the assistant can correct its input.

## Resources

| URI | Content |
| --- | --- |
| `einvoicing://reference/pint-ae-fields` | PINT AE business terms with UBL paths and the API field that carries each |
| `einvoicing://reference/editor-fields` | Optional field groups and their input keys |
| `einvoicing://reference/tax-categories` | VAT categories, rates and UBL codes |
| `einvoicing://reference/statuses` | Invoice, payment, review and incoming statuses; document kinds; transaction types |
| `einvoicing://reference/webhook-events` | Event catalogue |
| `einvoicing://openapi.json` | The OpenAPI document |
| `einvoicing://docs/llms.txt` | Short integration guide for language models |

## Example: a CargoWise message from a chat

> "Here is a UniversalTransaction from our CargoWise UAT. Check it, tell me what fails, fix our charge map and send it."

The assistant calls `dry_run_erp_message` with the XML and reads the mapping notes and failed rules (for example "Charge PSC has no mapping"). It proposes the mapping, calls `update_erp_connector` after you agree, runs the dry run again until it is valid, then calls `send_erp_message`. With a live key, `send_erp_message` is marked destructive, so the client asks you before it runs. If the message had already failed in the log, `replay_erp_message` processes it again instead.

## Example conversation

> "Create a tax invoice for Buyer LLC (TRN 100000000000011) for 10 consulting hours at AED 500, due in 30 days, submit it and tell me when the FTA accepts it."

The assistant calls `create_tax_invoice` with `submit: true`, reads `validation` and `submission` from the result, then polls `get_invoice_status` until `invoiceStatus` is `accepted` or `rejected`. With a webhook on `invoice.reported` your own system learns about it at the same moment.

## Limits

- Stateless: no sessions, no subscriptions, no server-initiated messages. Prompts are not provided (`prompts/list` is empty).
- Same rate limit as the API (600 requests per minute per key).
- `download_invoice` with `format: "pdf"` returns a download link (`url`, `expiresAt`, `filename`) that works for 15 minutes without a key, so an assistant can hand the PDF to the user instead of a very long base64 string. `delivery: "inline"` returns the base64 instead, and `delivery: "link"` gives a link for XML or HTML too. XML and HTML come back as text by default; `get_import_source` returns binary files as base64 in `structuredContent`.
