MCP Server ========== The MCP server exposes the Inkforje Accounting API as chat tools, so an AI assistant can create and manage accounting records from natural language: *"Create an invoice for Jane Doe: 12 DTF shirts at 15 each."* This page documents the server as built. For the original design rationale and the remaining roadmap, see :doc:`mcp_chat_integration_plan`. Architecture ------------ The server is built on the official MCP Python SDK (``mcp>=2.0``), so transport framing, protocol negotiation and JSON Schema generation come from the SDK rather than from code this project maintains. Every tool call goes out over HTTP through :class:`~accounting.mcp.client.InkforjeApiClient`, which means OAuth2 scopes and the DRF validation layer still apply. The MCP server has **no database access** and is not a trusted path into the ledger — it is an API consumer like any other integration. .. mermaid:: sequenceDiagram participant User as DTF Operator participant Client as Claude Code / Desktop participant MCP as MCP Server (stdio) participant API as Inkforje API participant DB as PostgreSQL User->>Client: "Create an invoice for Jane Doe..." Client->>MCP: list_items(search="DTF shirt") MCP->>API: GET /api/items/?search=DTF+shirt API-->>MCP: [{uuid, name, default_amount}, ...] MCP-->>Client: resolved item UUIDs Client->>MCP: create_invoice(lines=[{item_uuid, quantity}]) MCP->>API: OAuth2 token + POST /api/invoices/ API->>DB: Persist draft invoice API-->>MCP: JSON response MCP-->>Client: structured tool result Client-->>User: "Draft invoice INV-0042 created." Module Layout ------------- .. list-table:: :header-rows: 1 :widths: 45 55 * - Module - Responsibility * - ``accounting/mcp/server.py`` - Builds the ``MCPServer`` and registers every tool against an injected API client. * - ``accounting/mcp/client.py`` - OAuth2 client-credentials API client with token caching. Raises ``AccountingApiError`` carrying the API's own error body. * - ``accounting/mcp/schemas.py`` - Pydantic input models. These are both the validation layer and the JSON Schema the chat client sees. * - ``accounting/management/commands/run_mcp_server.py`` - Management command entrypoint. * - ``tests/accounting/test_mcp_server.py`` - Tool surface, annotations, validation, payload serialization and client transport behaviour. Tool Reference -------------- Thirteen tools are registered. ``*`` marks a required argument. Customers ^^^^^^^^^ .. list-table:: :header-rows: 1 :widths: 22 30 26 22 * - Tool - Arguments - Endpoint - Scope * - ``list_customers`` - ``search``, ``active`` - ``GET /api/customers/`` - ``transactions:read`` * - ``get_customer`` - ``customer_uuid*`` - ``GET /api/customers//`` - ``transactions:read`` * - ``create_customer`` - ``customer*`` - ``POST /api/customers/`` - ``transactions:write`` ``customer`` takes a ``CustomerInput`` object: ``customer_name*``, ``email``, ``phone``, ``address_1``, ``address_2``, ``city``, ``state``, ``zip_code``, ``country``, ``active``. Items ^^^^^ .. list-table:: :header-rows: 1 :widths: 22 30 26 22 * - Tool - Arguments - Endpoint - Scope * - ``list_items`` - ``search``, ``role`` - ``GET /api/items/`` - ``transactions:read`` * - ``get_item`` - ``item_uuid*`` - ``GET /api/items//`` - ``transactions:read`` ``role`` is one of ``product``, ``service``, ``inventory``, ``expense``. ``list_items`` is the resolution step for invoice creation. Invoice lines are keyed by item UUID, never by name, so a product name or SKU has to be resolved first. The server's ``instructions`` string tells the model this, which is why it generally calls ``list_items`` unprompted. Invoices ^^^^^^^^ .. list-table:: :header-rows: 1 :widths: 22 30 26 22 * - Tool - Arguments - Endpoint - Scope * - ``list_invoices`` - ``status``, ``customer_uuid``, ``external_order_id`` - ``GET /api/invoices/`` - ``transactions:read`` * - ``get_invoice`` - ``invoice_uuid*`` - ``GET /api/invoices//`` - ``transactions:read`` * - ``create_invoice`` - ``invoice*`` - ``POST /api/invoices/`` - ``transactions:write`` * - ``approve_invoice`` - ``invoice_uuid*``, ``date_approved`` - ``POST /api/invoices//approve/`` - ``transactions:write`` * - ``pay_invoice`` - ``invoice_uuid*``, ``date_paid`` - ``POST /api/invoices//pay/`` - ``transactions:write`` ``status`` is one of ``draft``, ``in_review``, ``approved``, ``paid``, ``void``, ``canceled``. ``invoice`` takes an ``InvoiceInput`` object: - ``lines*`` — list of ``InvoiceLineInput``: ``item_uuid*``, ``quantity*`` (must be greater than zero), ``unit_cost`` (overrides the item's ``default_amount``). At least one line is required. - ``customer_uuid`` **or** ``customer_name`` — one of the two is required. Supplying ``customer_name`` alone creates the customer on the fly. - ``customer_email``, ``external_order_id``, ``external_order_uri``, ``sales_channel``, ``fulfillment_type``, ``invoice_date``, ``terms``, ``currency_code``, ``shipping_address``, ``shipping_amount``, ``discount_amount``, ``external_payload``. Defaults: ``sales_channel="manual"``, ``fulfillment_type="delivery"``, ``terms="on_receipt"``, ``currency_code="CAD"``. Inventory ^^^^^^^^^ .. list-table:: :header-rows: 1 :widths: 22 30 26 22 * - Tool - Arguments - Endpoint - Scope * - ``list_inventory`` - *(none)* - ``GET /api/inventory/`` - ``inventory:read`` * - ``get_inventory_item`` - ``item_uuid*`` - ``GET /api/inventory//`` - ``inventory:read`` * - ``adjust_inventory`` - ``item_uuid*``, ``adjustment*`` - ``POST /api/inventory//adjust/`` - ``inventory:write`` ``adjustment`` takes a ``StockAdjustmentInput`` object: ``adjustment_type`` (``received``, ``adjustment``, ``return`` or ``write_off``), ``quantity*``, ``unit_cost``, ``reference``, ``notes``. Unlike invoice quantities, adjustment quantities may be negative — write-offs and corrections legitimately reduce stock. Safety Model ------------ Chat-driven accounting is only as safe as the confirmation step in front of it. Three things carry that weight. **Tool annotations.** Every tool declares an MCP annotation, and chat clients gate on it: .. list-table:: :header-rows: 1 :widths: 30 25 45 * - Tools - Annotation - Client behaviour * - All eight ``list_*`` / ``get_*`` tools - ``readOnlyHint`` - Called freely; no prompt. * - ``create_customer``, ``create_invoice`` - not destructive - Additive; creates a record but posts nothing. * - ``approve_invoice``, ``pay_invoice``, ``adjust_inventory`` - ``destructiveHint`` - Client prompts before calling. ``destructiveHint`` is load-bearing rather than decorative: it is what makes a client stop and ask before posting journal entries or moving stock, and none of those three actions is undoable from the tool surface. ``tests/accounting/test_mcp_server.py`` asserts these annotations so a regression cannot silently remove the prompt. **Draft-first.** ``create_invoice`` produces a draft. Nothing reaches the ledger until ``approve_invoice`` runs, so a misparsed quantity is a document to correct rather than a journal entry to reverse. **Strict schemas.** The Pydantic models set ``extra="forbid"``, so a hallucinated field name comes back as a validation error the model can correct instead of a silently dropped key. **Scopes split by method.** The customer, item and invoice views serve reads and writes from a single class. Declaring one ``required_scopes`` per view meant a read-scoped token could create records, so those views use ``accounting.api_permissions.TokenHasScopeForMethod``: safe methods take ``read_scopes``, everything else takes ``write_scopes``. A token holding only ``transactions:read`` can list and retrieve, and gets a 403 naming ``transactions:write`` if it tries to create or update. Setup ----- 1. Provision The OAuth Client ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Run this **on the server hosting the API** you intend to talk to: .. code-block:: bash uv run python manage.py provision_oauth_client \ --client-id inkforje-mcp \ --name "Inkforje MCP (chat)" \ --scopes "transactions:read transactions:write inventory:read inventory:write" The command creates a confidential ``client_credentials`` application and prints the secret once. .. note:: ``--scopes`` is informational. It validates the names against ``OAUTH2_PROVIDER["SCOPES"]`` and prints the string to use, but django-oauth-toolkit's ``Application`` model has no scope field, so nothing is persisted per-application. Enforcement happens per request: the token request asks for a scope set, and ``TokenHasScope`` checks it against each view's ``required_scopes``. ``InkforjeApiClient.DEFAULT_SCOPES`` requests all four scopes on every token call. 2. Configure Credentials ^^^^^^^^^^^^^^^^^^^^^^^^ The command reads three variables, falling back to CLI flags: .. list-table:: :header-rows: 1 :widths: 34 22 44 * - Variable - Flag - Value * - ``MCP_API_BASE_URL`` - ``--base-url`` - e.g. ``https://accounting.inkforje.com`` * - ``MCP_OAUTH_CLIENT_ID`` - ``--client-id`` - ``inkforje-mcp`` * - ``MCP_OAUTH_CLIENT_SECRET`` - ``--client-secret`` - the printed secret ``MCP_BASE_URL`` is accepted as a legacy alias for ``MCP_API_BASE_URL``. Put these in the project's ``.env``. Django settings call ``environ.Env.read_env()`` on startup, which populates ``os.environ``, so the command picks them up with no flags. ``.env`` is gitignored; keep it at mode ``600`` since it now holds a production credential. Do not put the secret into an MCP client config file. The launcher pattern below keeps it in one place. 3. Register With A Chat Client ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Create a launcher at ``~/.config/inkforje-mcp/run.sh`` (mode ``700``): .. code-block:: bash #!/bin/sh set -e cd /path/to/inkforje_accounting exec /Users//.local/bin/uv run --quiet python manage.py run_mcp_server "$@" Two details matter. ``cd`` into the project so Django loads ``.env`` regardless of which shell launched the process, and call ``uv`` by absolute path — GUI clients such as Claude Desktop start with a minimal ``PATH`` that excludes ``~/.local/bin``. Claude Code: .. code-block:: bash claude mcp add inkforje-accounting --scope user -- ~/.config/inkforje-mcp/run.sh claude mcp list # expect: inkforje-accounting - ✔ Connected Claude Desktop — add to ``~/Library/Application Support/Claude/claude_desktop_config.json`` and restart the app: .. code-block:: json { "mcpServers": { "inkforje-accounting": { "command": "/Users//.config/inkforje-mcp/run.sh" } } } Transports ---------- stdio is the default and is what local chat clients use: .. code-block:: bash uv run python manage.py run_mcp_server Deployment Architecture ^^^^^^^^^^^^^^^^^^^^^^^ The browser-authorized MCP endpoint is a **second process**, not another URL served by Django's WSGI/ASGI process. Both processes are launched from this repository and use the same Django settings and database, but they listen on different ports and have different responsibilities: .. list-table:: :header-rows: 1 :widths: 24 34 42 * - Process - Example public URL - Responsibility * - Django web server - ``https://accounting.example.com`` - Login and consent UI, OAuth endpoints, accounting API, and admin UI. * - MCP server - ``https://mcp.example.com/mcp`` - Streamable HTTP MCP endpoint, bearer-token validation, and tool exposure. The MCP process must be able to read the same database as Django because it validates the OAuth access tokens Django issues. Use the shared production database configuration (normally PostgreSQL) in both services. SQLite is useful for local development but is not recommended for concurrent production server processes. For local development, start each process in its own terminal: .. code-block:: bash # Terminal 1: Django authorization server and accounting API uv run python manage.py runserver 127.0.0.1:8000 .. code-block:: bash # Terminal 2: authenticated MCP endpoint uv run python manage.py run_mcp_server \ --transport streamable-http \ --host 127.0.0.1 \ --port 8001 \ --public-url http://127.0.0.1:8001/mcp \ --oauth-issuer http://127.0.0.1:8000 Register ``http://127.0.0.1:8001/mcp`` in the MCP client. The client discovers the authorization server from the MCP endpoint, launches the system browser, and returns to its loopback callback after the user signs in and approves the requested scopes. In production, supervise the two commands as separate systemd services or containers. Put both behind HTTPS and route their public hostnames to their respective internal ports. The MCP process still needs the confidential ``MCP_OAUTH_CLIENT_ID`` and ``MCP_OAUTH_CLIENT_SECRET`` for its server-to-server accounting API calls; those values stay in the MCP service environment and are never provisioned to the connecting MCP client. The repository's ``Deploy Production`` GitHub Actions workflow installs and monitors ``inkforje-accounting-mcp.service`` alongside the web, worker, beat, and Flower services. Add these entries to the production ``DEPLOYMENT_SECRETS`` environment file: .. code-block:: text MCP_PORT=8001 MCP_API_BASE_URL=https://accounting.example.com MCP_PUBLIC_URL=https://mcp.example.com/mcp MCP_OAUTH_ISSUER=https://accounting.example.com MCP_OAUTH_CLIENT_ID= MCP_OAUTH_CLIENT_SECRET= The production workflow provisions or updates that confidential client after migrations. It reads the secret from ``MCP_OAUTH_CLIENT_SECRET`` without placing the secret in process arguments, ensuring the database and MCP service environment stay synchronized. These six MCP values are configured as individual GitHub repository secrets, not embedded in ``DEPLOYMENT_SECRETS``. The workflow appends them to the deployed ``.env`` without replacing the existing secret bundle. The workflow requires HTTPS for all three public URLs and rejects an MCP port that is invalid or collides with ``APP_PORT``. The systemd service binds the MCP process to ``127.0.0.1:${MCP_PORT}``; configure the production reverse proxy or Cloudflare Tunnel to forward the public ``MCP_PUBLIC_URL`` to that listener. Before any production mutation, a GitHub-hosted verification job installs the locked development environment and runs Ruff, Django's system check, and the complete pytest suite. The self-hosted deployment job runs only after all three checks pass. Browser authorization applies only to HTTP transports. The MCP authorization spec deliberately leaves ``stdio`` credentials to the local environment, so a stdio connection does not launch a browser. For a hosted gateway, use an HTTP transport: .. code-block:: bash uv run python manage.py run_mcp_server \ --transport streamable-http \ --host 0.0.0.0 \ --port 8001 \ --public-url https://mcp.example.com/mcp \ --oauth-issuer https://accounting.example.com Remote HTTP clients use browser-based OAuth rather than receiving the service client secret. On first connection the client discovers the Django authorization server, dynamically registers a secretless public client, opens ``/oauth/authorize/`` in the user's browser, and exchanges the returned code with PKCE. The client stores and refreshes its own token. The confidential credentials configured on the MCP process remain server-side and are only used when the MCP tools call the accounting API. Set ``MCP_PUBLIC_URL`` and ``MCP_OAUTH_ISSUER`` instead of the two flags in a service environment. ``MCP_PUBLIC_URL`` must be the externally reachable MCP endpoint (normally ending in ``/mcp``), not its bind address. HTTP transports refuse to start without it because OAuth resource metadata must contain a stable, public resource identifier. ``--host`` and ``--port`` default to ``127.0.0.1:8001`` and apply only to the ``sse`` and ``streamable-http`` transports. The port default avoids ``8000``, which collides with ``manage.py runserver``. .. warning:: Under stdio the MCP wire protocol *is* the process's stdout. Anything else written there corrupts the stream, so keep logging on stderr. Django's default logging already goes to stderr; take care if you add a ``LOGGING`` setting with a stdout handler. Authentication Flow ------------------- ``InkforjeApiClient`` fetches a token on first use and caches it in memory: 1. ``POST /oauth/token/`` with HTTP Basic auth (client ID and secret), ``grant_type=client_credentials`` and the four scopes. 2. The token is cached until ``expires_in`` minus a 30-second refresh margin, floored at 60 seconds. Expiring locally before the server does avoids a 401 mid-tool-call. 3. Every API request sends ``Authorization: Bearer ``. Token lifetime is set server-side by ``OAUTH2_PROVIDER["ACCESS_TOKEN_EXPIRE_SECONDS"]`` (default ``3600``). The cache is per-process, so restarting the server re-fetches. Worked Examples --------------- **Create a draft invoice for an existing customer.** *"Create an invoice for Jane Doe: 12 DTF shirts at 15 each."* The assistant resolves the references before writing anything: 1. ``list_customers(search="Jane Doe")`` → customer UUID. 2. ``list_items(search="DTF shirt")`` → item UUID. 3. ``create_invoice(invoice={customer_uuid: ..., lines: [{item_uuid: ..., quantity: 12, unit_cost: 15}]})``. The result is a draft. Nothing has been posted. **Approve it.** *"Approve that invoice."* ``approve_invoice`` carries ``destructiveHint``, so the client prompts first. Approving posts journal entries to the ledger. **Check stock.** *"How many black hoodies do we have on hand?"* ``list_inventory`` or ``get_inventory_item`` — both read-only, so no prompt. On-hand is received minus invoiced. **Receive stock.** *"We received 50 more black hoodies at 8.25 each."* ``adjust_inventory`` with ``adjustment_type="received"``. Destructive, so the client prompts first. Troubleshooting --------------- .. list-table:: :header-rows: 1 :widths: 38 62 * - Symptom - Cause and fix * - ``API request failed (401): {'error': 'invalid_client'}`` - The client ID or secret is wrong, or the OAuth application was never provisioned on the target host. A client provisioned on dev does not exist in production. * - ``API request failed (403)`` - The token lacks the scope the view requires; the error body names the missing scope. Creating or updating needs ``transactions:write``, not just ``transactions:read``, and inventory tools need the ``inventory:*`` pair. Easy to hit with a token minted by hand rather than by the client, which always requests all four. * - Tool calls fail but the session stays alive - Expected. API failures come back as ``isError`` results carrying the DRF error body, not as transport errors — the assistant can read the message and retry. * - Client shows no tools - Run the launcher directly in a terminal. If Django fails to start, the error appears on stderr; a GUI client will just show a failed server. * - Client works in the terminal but not in Claude Desktop - Almost always ``PATH``. Call ``uv`` by absolute path in the launcher. * - Invoice creation fails on an unknown item - Lines are keyed by UUID. Call ``list_items`` first; a name or SKU is not accepted in ``item_uuid``. * - Garbled protocol errors on stdio - Something wrote to stdout. Check for print statements or a ``LOGGING`` config with a stdout handler. Development ----------- Run the test suite for the MCP surface: .. code-block:: bash uv run pytest tests/accounting/test_mcp_server.py The tests stub the HTTP layer, so no server or credentials are needed. They cover the tool surface and annotations, Pydantic validation, payload serialization, and the client's token caching and error extraction. To inspect the tool surface without a chat client: .. code-block:: python import asyncio from accounting.mcp.client import InkforjeApiClient from accounting.mcp.server import build_server server = build_server(InkforjeApiClient(base_url="http://x", client_id="a", client_secret="b")) for tool in asyncio.run(server.list_tools()): print(tool.name, sorted(tool.input_schema.get("properties", {}))) ``build_server`` takes the API client as an argument, so any object with the same method names can stand in for it — that is how the tests avoid HTTP. When adding a tool, keep the field constraints in ``accounting/mcp/schemas.py`` in step with ``accounting/api_serializers.py``. The Pydantic model is what the chat client validates against, so a constraint that drifts from the API turns a clear client-side validation error into a confusing round-trip 400.