The Model Context Protocol turns an application into something a language model
can operate. The protocol itself is small — JSON-RPC 2.0, five methods, an
inputSchema per tool — and you can have a working server in an afternoon.
The afternoon is not where the difficulty is. The difficulty is that you have just built an interface to your production database whose caller is non-deterministic, persuadable by its own input, and enthusiastic. Every design decision that was merely tidy in a REST API becomes load-bearing.
This is the MCP server in front of AltiusOne, a Swiss fiduciary and accounting platform — clients, mandates, invoicing, time tracking, double-entry accounting, payroll. Twenty-six tools over the business model, and the reasoning for the shape.
Twenty-six typed tools, not one query tool
The first decision, and the one people most often get wrong in the other direction.
TOOLS = [
{
"name": "search_clients",
"description": "Search clients (customers) by name, IDE number, or status. "
"Returns a list of matching clients with key info.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search term (name, IDE number, email)"},
"statut": {"type": "string", "description": "Filter by status: ACTIF, INACTIF, ANNULE"},
"limit": {"type": "integer", "description": "Max results (default 20)", "default": 20},
},
},
},
...
]
Twenty-six of these: search_clients, get_mandat, factures_impayees,
balance_generale, chiffre_affaires, list_fiches_salaire, graph_search…
The tempting alternative is one tool — run_query(sql) or
orm_query(model, filters) — which is a tenth of the code and covers everything.
It is the wrong shape for four reasons, and they compound:
A generic tool has no security surface you can reason about. With twenty-six tools you can state, per tool, who may call it and what it returns. With one, the question “can this caller see payroll?” has no answer short of parsing the query.
A generic tool cannot be described. The model chooses tools by reading their
descriptions. "Search mandates (engagements/contracts) by number, client name, or
status" tells it exactly when this is the right call. "Run an ORM query" tells
it nothing, so it guesses schema — and it guesses plausibly, which is worse than
guessing badly, because the errors look like answers.
A generic tool exposes your schema as your API. Every rename becomes a breaking change to something you cannot version, because the caller is a model that read your column names once.
A typed tool can be optimised. _search_clients carries its own
select_related("adresse_siege", "responsable") and an annotated count. A generic
executor cannot know which joins matter.
The cost is real — twenty-six schemas and twenty-six functions to maintain — and it buys the only thing that matters here: a bounded, describable, individually auditable surface.
The description is the API
Worth isolating, because it is the part with no analogue in ordinary API design.
"description": "Get detailed info about a mandate: client, team, budget, fiscal years, billing type."
That sentence is not documentation. It is the routing logic. The model reads descriptions and picks. Two consequences follow immediately:
Descriptions must disambiguate against each other, not just describe.
search_mandats and get_mandat differ by one letter and by everything else;
“search … by number, client name, or status” versus “get detailed info about a
mandate” is what stops a model from calling the list endpoint twenty times to
find one row.
Domain terms need their translation in-line. "Search mandates
(engagements/contracts)" — the platform’s word is mandat, the user will say
“contract” or “engagement”, and the parenthesis is what connects them. Likewise
"Filter by status: ACTIF, INACTIF, ANNULE": enumerating the literal values in
the description means the model sends ACTIF rather than active, and you do not
need a normalisation layer for a problem you could have avoided by writing three
words.
Treat descriptions as prompt engineering, because that is what they are. They are also the cheapest place in the system to fix a behaviour problem.
Where the authentication line is drawn
@csrf_exempt
@require_http_methods(["POST"])
def mcp_endpoint(request):
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return _error_response(-32700, "Parse error", status=400)
method = body.get("method", "")
params = body.get("params", {})
request_id = body.get("id")
# initialize does not require auth (lets client discover capabilities)
if method == "initialize":
return _success_response(SERVER_INFO, request_id)
# All other methods require authentication
user, auth_error = authenticate_request(request)
if auth_error:
return _error_response(auth_error["code"], auth_error["message"], request_id, status=401)
result = _handle_method(method, params, user)
return _success_response(result, request_id)
One method is unauthenticated, and exactly one. The line is drawn in the right place, and it is worth seeing why it is not obvious.
initialize returns SERVER_INFO:
SERVER_INFO = {
"name": "altiusone",
"version": "2.0.0",
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}, "resources": {}},
}
A name, a version, a protocol version, and two empty capability objects. It says this endpoint speaks MCP 2024-11-05 and has tools and resources — and nothing about what they are. Capability negotiation has to happen before a client can know how to authenticate meaningfully, and refusing it produces clients that cannot report a useful error.
tools/list is on the other side of the line, and that is the important half.
The catalogue is not public. Knowing that a server exposes
list_fiches_salaire and balance_generale is knowing the shape of the business
and the names of the doors. An unauthenticated tools/list is a free
reconnaissance endpoint, and it is a very common mistake because the protocol
groups list and call as sibling methods.
The rule generalises: advertise the protocol, never the inventory.
Two authentication schemes, for two kinds of caller
def authenticate_request(request):
"""
Accepts:
- Bearer <jwt_token> (SimpleJWT, short-lived)
- Token <api_token> (DRF authtoken, persistent)
"""
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if auth_header.startswith("Token "):
return _auth_drf_token(auth_header[6:].strip())
elif auth_header.startswith("Bearer "):
return _auth_jwt(request)
else:
return None, {
"code": -32000,
"message": "Authentication required. Send 'Authorization: Token <api_token>' "
"or 'Authorization: Bearer <jwt>'",
}
Not redundancy — two genuinely different callers:
- A short-lived JWT for a session where a human is present: the model is acting inside a logged-in session, the token expires, revocation is implicit.
- A persistent token for a client configured once — a desktop MCP client, a scheduled agent. Nobody is there to refresh a JWT at three in the morning, and a refresh flow inside an MCP client is a specification nobody has written.
Two properties of this function are worth copying.
The error message says how to authenticate. "Send 'Authorization: Token
<api_token>' or 'Authorization: Bearer <jwt>'". The consumer of that message may
be a model configuring itself, and a model that is told the format will use it. A
bare 401 Unauthorized produces a retry loop.
is_active is checked on both paths.
token = Token.objects.select_related("user").get(key=key)
if not token.user.is_active:
return None, {"code": -32000, "message": "User account is disabled"}
A DRF token survives the user being deactivated — deactivation sets a flag, it does not delete tokens. An MCP endpoint that skips this check is a working credential for a departed employee, and it is the single most likely way for one of these servers to outlive its authorisation.
Authentication is not authorisation, and MCP makes the gap easy to miss
This is the section to read twice if you are building one of these.
Every tool has the same signature:
def execute_tool(name, arguments, user):
fn = _TOOL_DISPATCH.get(name)
if not fn:
return {"error": f"Unknown tool: {name}"}
return fn(arguments, user)
user is threaded through to every tool. That is the right plumbing, and it is
also the trap, because plumbing that carries a user looks exactly like
plumbing that enforces one. The signature reads as though authorisation is
handled. Nothing about def _search_clients(args, user) tells you whether user
is consulted inside.
The property that has to hold is stronger than “the request was authenticated”:
Every tool must return exactly what this user would see in the interface — no more. The model is a client acting on behalf of a person, and it inherits that person’s permissions, not the application’s.
Concretely, for a fiduciary platform, that means three distinct things:
Scope the queryset, not the response. Client.objects.filter(is_active=True)
is a business filter. The authorisation filter is a different clause —
.filter(responsable=user), or a manager that takes the user, or a
for_user(user) on a custom QuerySet. Filtering after the fact, in the
serialisation loop, is a filter someone will forget when they add a field.
Gate the sensitive tools by capability, not by authentication. Payroll,
general ledger, and per-employee records are not “any logged-in user” data. If
the platform already has roles — and AltiusOne does; user.role.code is exposed
to the model as a resource — the tool must consult them:
def _list_fiches_salaire(args, user):
if not user.has_perm("rh.view_fichesalaire"):
return {"error": "Not permitted"}
...
Make it structural rather than remembered. Twenty-six tools each of which must remember to check is twenty-six chances to forget, and the twenty-seventh tool will be written in a hurry. The shape that survives is a declaration next to the tool and a dispatcher that enforces it:
TOOL_PERMISSIONS = {
"list_fiches_salaire": "rh.view_fichesalaire",
"balance_generale": "compta.view_ecriture",
"create_tache": "core.add_tache",
...
}
def execute_tool(name, arguments, user):
fn = _TOOL_DISPATCH.get(name)
if not fn:
return {"error": f"Unknown tool: {name}"}
required = TOOL_PERMISSIONS.get(name)
if required is None:
return {"error": f"Tool {name} has no declared permission"} # fail closed
if not user.has_perm(required):
return {"error": "Not permitted"}
return fn(arguments, user)
The required is None branch is the load-bearing one: a tool added without a
declared permission does not run. Fail closed, so the default state of a
forgotten line is a broken tool rather than an open door. And the same table can
filter tools/list, so a caller is only offered what they may call — which also
stops the model wasting turns on tools it will be refused.
The reason this is worth belabouring is that MCP changes who the caller is. A REST endpoint is called by code someone wrote deliberately. An MCP tool is called by a model that read a description and decided. It will try things. It will combine tools in orders nobody designed. If a tool is reachable and returns data, it will eventually be called with arguments nobody anticipated — and the person who sees the result may not be the person who was supposed to.
Bounded results, always
limit = min(args.get("limit", 20), 100)
Present on every list tool, and it is not defensive coding — it is a protocol requirement in disguise.
The caller is optimising for having enough context, so it will ask for a thousand rows. Three things then go wrong at once: the query gets expensive, the JSON gets large, and — the one people forget — the model’s context window fills with rows it will not read. A thousand invoices in a context window is not more information; it is less, because the answer is now buried and the model has less room to reason.
min(x, 100) rather than a validation error: a model that gets an error will
retry, often with the same value. Clamping succeeds, returns useful data, and
costs one round trip instead of three.
Formatting is centralised because JSON has no decimals
def _fmt_decimal(val):
"""Format decimal for JSON output."""
if val is None:
return None
return float(val) if isinstance(val, Decimal) else val
def _fmt_date(val):
if val is None:
return None
return val.isoformat() if hasattr(val, "isoformat") else str(val)
def _addr_str(adresse):
if not adresse:
return None
parts = [adresse.rue]
if adresse.numero:
parts[0] = f"{adresse.rue} {adresse.numero}"
if adresse.complement:
parts.append(adresse.complement)
parts.append(f"{adresse.code_postal} {adresse.localite}")
if adresse.canton:
parts[-1] += f" ({adresse.canton})"
return ", ".join(parts)
Decimal is not JSON-serialisable, and every accounting tool returns money. Three
helpers instead of a default=str in json.dumps — which would work, and would
produce "1234.50" as a string, which a model then has to parse, and sometimes
parses as 1234.5 and sometimes reformats as 1,234.50 in its answer.
_fmt_date returning ISO-8601 is the same argument with sharper teeth: a model
handed 03/04/2026 has no way to know whether that is April or March, and it will
pick one confidently. ISO is unambiguous, and it is the format models are most
reliably trained to parse.
_addr_str earns its place for a different reason: the model should not have
to assemble anything. A structured address across six fields invites it to
concatenate them, in an order it infers, with separators it invents. One
pre-formatted string is one fewer opportunity to be creative.
Writing tools, and the constraint that would have failed
Twenty-four of the tools read. Two write, and the writes are where the design has to be most careful.
def _create_tache(args, user):
tache = Tache(
titre=args["titre"],
description=args.get("description", ""),
priorite=args.get("priorite", "NORMAL"),
cree_par=user,
)
...
# Prestation optionnelle : si fournie → tâche facturable ; sinon tâche
# interne non facturée (cf. Tache.facturable). Évite l'IntegrityError
# quand l'appelant MCP ne fournit pas de prestation.
if args.get("prestation_id"):
tache.prestation = Prestation.objects.get(pk=args["prestation_id"], is_active=True)
tache.facturable = True
else:
tache.facturable = False
tache.save()
cree_par=user — attribution, not authorisation, but necessary. Anything a
model creates must be traceable to the person on whose behalf it acted. “Who
created this task?” must never answer “the API”.
The prestation branch is the interesting one. A task with a billable
service is billable; without one it is internal. In the web interface, the form
makes that choice explicit. Over MCP the caller frequently omits it — and the
naive implementation hits an IntegrityError, which surfaces to the model as a
failure it will retry, identically, forever.
The fix is not to accept a broken row. It is to notice that the model has a
defined meaning — a task with no service is an internal task — and to encode
it. The general rule: every optional argument in an MCP tool needs a defined
semantic for its absence, because absence is the common case. A schema that says
"required": ["titre"] is a promise that everything else has an answer when it
is missing.
is_active=True on both lookups. A model given an id it read in an earlier
response may hand you a soft-deleted one, and linking a live task to a deleted
service is a data problem discovered at invoicing.
The error path returns too much
One last thing, and it is the most commonly shipped mistake in these servers.
except Exception as e:
logger.error(f"MCP tool error ({name}): {e}", exc_info=True)
return {"error": str(e)}
Catching broadly is right — a tool failure must not take down the endpoint, and
the log keeps the traceback. Returning str(e) to the caller is not.
Exception messages are written for developers and contain what developers want:
table names, column names, constraint names, the value that violated a unique
index. Client matching query does not exist is harmless.
duplicate key value violates unique constraint "core_client_ide_number_key"
DETAIL: Key (ide_number)=(CHE-123.456.789) already exists. is a database schema
lesson and a leaked identifier, delivered into a model’s context window —
which may be transcript-logged, sent to a third-party inference provider, and
shown to whoever is watching the conversation.
The shape that keeps the diagnosis without the disclosure:
except Exception as e:
error_id = uuid.uuid4().hex[:8]
logger.error("MCP tool error (%s) [%s]: %s", name, error_id, e, exc_info=True)
return {"error": f"Tool '{name}' failed. Reference: {error_id}"}
The full detail is in the log, where the developer is. The caller gets something it can report and a human can look up. Expected, non-sensitive failures — no such client, not permitted — should be raised and caught as their own types above this handler, so the useful messages stay useful and only the unexpected ones get anonymised.
What to take away
- Many typed tools, never one generic query tool. The surface has to be describable, auditable and individually permissioned.
- Descriptions are routing logic. Disambiguate against neighbours, spell out enum values, gloss domain vocabulary.
initializemay be public;tools/listmay not. Advertise the protocol, never the inventory.- Support a short-lived token and a persistent one, and check
is_activeon both. - Authentication is not authorisation. Scope the queryset, declare a
permission per tool, and fail closed on a tool with no declaration — a
userargument that is merely passed around proves nothing. - Clamp every limit, and remember the third cost is the context window.
- Give every optional argument a defined meaning when absent. Absence is the normal case over MCP.
- Never return raw exception text to a model. Log the detail, return a reference.