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 |
|---|---|
|
Builds the |
|
OAuth2 client-credentials API client with token caching. Raises
|
|
Pydantic input models. These are both the validation layer and the JSON Schema the chat client sees. |
|
Management command entrypoint. |
|
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 |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
customer takes a CustomerInput object: customer_name*, email,
phone, address_1, address_2, city, state, zip_code,
country, active.
Items¶
Tool |
Arguments |
Endpoint |
Scope |
|---|---|---|---|
|
|
|
|
|
|
|
|
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 |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status is one of draft, in_review, approved, paid, void,
canceled.
invoice takes an InvoiceInput object:
lines*— list ofInvoiceLineInput:item_uuid*,quantity*(must be greater than zero),unit_cost(overrides the item’sdefault_amount). At least one line is required.customer_uuidorcustomer_name— one of the two is required. Supplyingcustomer_namealone 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 |
|---|---|---|---|
|
(none) |
|
|
|
|
|
|
|
|
|
|
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 |
|
Called freely; no prompt. |
|
not destructive |
Additive; creates a record but posts nothing. |
|
|
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 |
|---|---|---|
|
|
e.g. |
|
|
|
|
|
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 |
|
Login and consent UI, OAuth endpoints, accounting API, and admin UI. |
MCP server |
|
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:
POST /oauth/token/with HTTP Basic auth (client ID and secret),grant_type=client_credentialsand the four scopes.The token is cached until
expires_inminus a 30-second refresh margin, floored at 60 seconds. Expiring locally before the server does avoids a 401 mid-tool-call.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:
list_customers(search="Jane Doe")→ customer UUID.list_items(search="DTF shirt")→ item UUID.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 |
|---|---|
|
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. |
|
The token lacks the scope the view requires; the error body names the
missing scope. Creating or updating needs |
Tool calls fail but the session stays alive |
Expected. API failures come back as |
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 |
Invoice creation fails on an unknown item |
Lines are keyed by UUID. Call |
Garbled protocol errors on stdio |
Something wrote to stdout. Check for print statements or a |
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.