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 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 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.

        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

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

Tool

Arguments

Endpoint

Scope

list_customers

search, active

GET /api/customers/

transactions:read

get_customer

customer_uuid*

GET /api/customers/<uuid>/

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

Tool

Arguments

Endpoint

Scope

list_items

search, role

GET /api/items/

transactions:read

get_item

item_uuid*

GET /api/items/<uuid>/

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

Tool

Arguments

Endpoint

Scope

list_invoices

status, customer_uuid, external_order_id

GET /api/invoices/

transactions:read

get_invoice

invoice_uuid*

GET /api/invoices/<uuid>/

transactions:read

create_invoice

invoice*

POST /api/invoices/

transactions:write

approve_invoice

invoice_uuid*, date_approved

POST /api/invoices/<uuid>/approve/

transactions:write

pay_invoice

invoice_uuid*, date_paid

POST /api/invoices/<uuid>/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

Tool

Arguments

Endpoint

Scope

list_inventory

(none)

GET /api/inventory/

inventory:read

get_inventory_item

item_uuid*

GET /api/inventory/<uuid>/

inventory:read

adjust_inventory

item_uuid*, adjustment*

POST /api/inventory/<uuid>/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:

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:

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:

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):

#!/bin/sh
set -e
cd /path/to/inkforje_accounting
exec /Users/<you>/.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:

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:

{
  "mcpServers": {
    "inkforje-accounting": {
      "command": "/Users/<you>/.config/inkforje-mcp/run.sh"
    }
  }
}

Transports

stdio is the default and is what local chat clients use:

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:

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:

# Terminal 1: Django authorization server and accounting API
uv run python manage.py runserver 127.0.0.1:8000
# 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:

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=<confidential service client id>
MCP_OAUTH_CLIENT_SECRET=<confidential service 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:

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>.

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

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:

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:

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.