Model Context Protocol
https://api.granska.cloud/v1/mcpSpeaks the Model Context Protocol, so an AI assistant can use this API without anybody writing code against it.
What this is for
Every other page here describes a call you make from code you wrote. This one describes the same gateway spoken to by an AI assistant — Claude, ChatGPT or anything else that implements the Model Context Protocol. You give the assistant this URL and a bearer token, and it discovers what is here for itself.
It is the same gateway, the same credentials and the same tenant as every other route. Nothing about your account changes because a call arrived over MCP.
Discovery. server/discover reports which protocol revision this server speaks and what it is
capable of, and tools/list answers with the review workflow as nine tools: upload_url,
analyze, get_job, get_profiles, action, search_laws, get_law, get_snippets and
get_quotas. Each one is generated from the same table that describes the routes on the rest of this
reference, so a tool cannot say anything the route it stands for does not do.
Calling. tools/call runs the route the named tool stands for, under your own token and for your
own organisation. params.name is the tool and params.arguments its arguments, and the arguments
are held to the tool's own inputSchema — an argument the schema does not offer is refused with
-32602 rather than passed on, which is how a tool that deliberately withholds one keeps it
withheld. The result comes back as a text block carrying the route's own JSON.
What a call costs is what the route costs. analyze and action each draw a run from the same
quota, the same counter and the same period as calling POST /v1/analyze or POST /v1/action
directly — one run per granskningsprofil, so a granskningspaket costs one per member. get_law
draws from a second counter, the hourly configuration-write floor, and only on the calls that have
to fetch: a law this API already holds is free, one it has to go and get ticks the floor once. The
other six tools draw nothing, and neither does tools/list. A call that the route then refuses
gives the run straight back, exactly as it does over HTTP.
get_quotas is how an assistant reads those counters before it spends one. The X-RateLimit-*
headers below are read by the MCP client library, not by the model driving it — so without this tool
an assistant can only discover that a review is unaffordable by starting one and being refused, which
on analyze happens after it has already decided to spend. The tool takes no arguments, draws
nothing itself, and reports both counters: the run quota and the hourly configuration-write floor.
A get_law the floor refuses arrives as a tool execution error, not as a 429. The three
X-RateLimit-* headers on this endpoint always describe the run quota, never the floor, and a
route's own headers are not forwarded — so a floor refusal answered as a 429 would arrive under
numbers that never moved and invite an assistant to back off from the wrong counter. It comes back
inside a 200 instead, carrying the floor's own sentence, beside run-quota headers that may well
look untouched. Read the floor from GET /v1/quotas, which reports both
counters — over MCP that is the get_quotas tool.
Two answers are the gateway's rather than the protocol's, and both are HTTP rather than JSON-RPC: a
missing or unreadable credential is 401, and a spent run quota is 429 TOO_MANY_REQUESTS —
whether the quota was already spent when the call arrived or ran out while the route was resolving
how many runs it needed. Every answer the endpoint's own handler produces carries
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, that 429 and every tool
execution error included, so an assistant can see where the period stands without having to spend a
run to find out. Two answers do not carry them, and neither is a fault in your client. A 401 is
refused above the handler, so there is no organisation to report a quota for. And the numbers are
read after the call has run — a tools/call that starts an analysis has to be counted before they
are true — so on the rare occasion that read itself fails, the answer is sent without the three
headers rather than thrown away: it may already name an analysis that is queued and charged, and
that name is the only way to fetch it. What you lose there is one reading of where the period
stands, not the review.
curl https://api.granska.cloud/v1/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: server/discover" \
-d '{
"jsonrpc": "2.0",
"id": "discover-1",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'{
"jsonrpc": "2.0",
"id": "discover-1",
"result": {
"resultType": "complete",
"supportedVersions": [
"2026-07-28"
],
"capabilities": {
"tools": {}
},
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "Utredningsgranskaren",
"version": "2026-07-28"
}
},
"ttlMs": 3600000,
"cacheScope": "public"
}
}The revision this server speaks
2026-07-28, and no other.
That revision removed protocol sessions, the initialize handshake and the Mcp-Session-Id header.
Every request carries its own protocol version and its own capabilities in _meta, so there is no
connection state to establish and none to lose — which is what lets this live on one ordinary POST
behind the same authentication as everything else.
A client that declares an earlier revision is refused with 400 and JSON-RPC code -32022, and the
error's data.supported lists what this server does speak:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": { "supported": ["2026-07-28"], "requested": "2025-11-25" }
}
}
That holds for a client that never heard of _meta either. The field is this revision's own, so an
assistant built for 2025-06-18 declares its version in the two places that revision defines
instead: in the body of the initialize request it opens with, as params.protocolVersion, and in
the MCP-Protocol-Version header on every request after that. Both are read when _meta is absent,
so the refusal is -32022 with data.supported from the assistant's very first call onwards, rather
than a complaint about a missing field it cannot produce.
That is an answer a conforming client acts on. If your assistant cannot be moved to 2026-07-28, it
cannot use this endpoint yet — tell us, because that is the sort of thing that decides what gets
built next.
| Parameter | Description |
|---|---|
MCP-Protocol-Version"2026-07-28"·header·required | The protocol revision this request uses. Must equal the version in params._meta.Any other revision is refused with JSON-RPC code -32022, whose data.supported lists what this server speaks. |
Mcp-Methodstring·header·required | The body's method, mirrored into a header so gateways can route without parsing the body. Must equal it. |
Mcp-Namestring·header | Required only on tools/call, resources/read and prompts/get, where it mirrors params.name or params.uri. Of the three, this server implements tools/call, where it must equal params.name. |
Two envelopes, and where the line runs
This is the one endpoint on the gateway that answers in two different shapes, and it is worth ten seconds of your attention because a client that reads only one of them will throw at the worst moment.
Above the handler, it is an ordinary route. A missing, unreadable or expired bearer token is refused by the same middleware that refuses every other route, in the envelope every other page here documents:
{
"error": {
"code": "UNAUTHORIZED",
"message": "Unauthorized",
"documentation_url": "https://www.granska.cloud/docs/api/errors#UNAUTHORIZED"
}
}
Past it, it is JSON-RPC. Everything the transport itself decides — a missing header, a malformed
body, an unknown method — comes back as a JSON-RPC error, whose code is a number from the
protocol's own number space and not one of this API's error codes:
| Code | HTTP | When |
| --- | --- | --- |
| -32600 | 400 | The body is not a single JSON-RPC request or notification. No batching, and never a response. |
| -32602 | 400 | params._meta is missing io.modelcontextprotocol/protocolVersion or io.modelcontextprotocol/clientCapabilities. |
| -32020 | 400 | A required header is missing, or a header disagrees with the body it mirrors. |
| -32022 | 400 | The request declares a protocol revision this server does not implement. |
| -32601 | 404 | No such method. Anything but server/discover, tools/list and tools/call. |
tools/call adds two of its own, both -32602: a tool no tools/list reports (404), and
arguments that do not satisfy the tool's inputSchema (400). A route that runs and then refuses
is not an error at this level — it comes back as an ordinary result with isError set and the
route's own message inside, which is the half the specification asks clients to hand back to the
model so it can correct itself and try again.
An MCP client library handles all of these for you. You will only meet them by hand while wiring up
credentials — and if you meet a 401, it is HTTP, not MCP.
| Error | When |
|---|---|
401UNAUTHORIZED | The Authorization header is missing, is not a readable bearer token, or names no tenant. |
401TOKEN_EXPIRED | The access token was issued by this gateway and has since expired. Not probed: it needs a token older than its own lifetime. |
429TOO_MANY_REQUESTS | A tools/call naming a tool that starts an analysis found the organisation's run quota spent for the current period — either when the call arrived, or while the route was working out how many runs it needed, which is where a granskningspaket meets the ceiling one member profile at a time. Only a tool that costs a run can reach it: tools/list, server/discover and a tools/call on a read-only tool are never refused this way. Not probed: reaching it would mean spending a real organisation's whole period. |
500INTERNAL_ERROR | An unexpected server-side failure. Not probable from outside — reaching it means something is wrong. |
Sending a request by hand
Three headers beyond the token, because this transport mirrors parts of the body into headers so that gateways and proxies can route without parsing JSON:
MCP-Protocol-Version— must equal the version inparams._meta.Mcp-Method— must equal the body'smethod.Mcp-Name— only ontools/call,resources/readandprompts/get, where it mirrorsparams.nameorparams.uri. Required ontools/call, where it must equal the tool name inparams.name, and refused onserver/discoverandtools/list, which take no name at all. A name that cannot be a plain ASCII header value is sent Base64-wrapped as=?base64?…?=, and is compared decoded.
If a header and the body disagree, the request is refused rather than resolved in favour of one of them: that disagreement is exactly how a request gets routed as one thing and executed as another.
tools/list answers the tools, and says how long you may cache them:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"tools": [
{
"name": "get_job",
"description": "Reads the state of one job, and its result once the job has finished.\n\nPoll this until status is COMPLETED or FAILED. A finished job is removed 15 minutes after its last update.\n\nCalls GET /v1/jobs/:jobId.",
"inputSchema": {
"type": "object",
"properties": {
"jobId": {
"type": "string",
"minLength": 1,
"description": "The id POST /v1/analyze or POST /v1/action returned."
}
},
"additionalProperties": false,
"required": ["jobId"]
}
}
],
"ttlMs": 60000,
"cacheScope": "private"
}
}
One minute, and private to your own client. Both are deliberate: the list will be derived from what your tenant may reach, so it is never a shared cache — and a short life means your assistant picks up a change to it within the minute.
Two things the descriptions say out loud, because an assistant gets both wrong otherwise. The
document never travels through the assistant's context: upload_url returns a job id and a URL,
whatever holds the file uploads it there, and analyze is then given that job id — pdfBase64 is
not offered as a tool argument at all. And a pinpoint is the id a provision is addressed by,
par_7§, never the label it is printed as, 7 §.
| Parameter | Description |
|---|---|
jsonrpc"2.0"·body·required | The JSON-RPC version. Always the string 2.0. |
idstring | number·body | Correlates the answer with the call. Omit it entirely to send a notification, which is answered 202 with no body.Never null. This revision of MCP forbids a null id, and one is refused rather than read as a notification. |
methodstring·body·required | Which MCP method to call: server/discover, tools/list or tools/call. Anything else is 404 with JSON-RPC code -32601.tools/call runs the route the named tool stands for, with your own token and organisation, and costs exactly what calling that route directly would cost — a tool that starts an analysis draws a run from the same quota, one that only reads draws nothing. params.name is the tool and params.arguments its arguments, which must satisfy the tool's own inputSchema: an argument that schema does not offer is refused with -32602 rather than passed on. |
paramsobject·body·required | The method's arguments. Its _meta must carry "io.modelcontextprotocol/protocolVersion" and "io.modelcontextprotocol/clientCapabilities" on every request.A request missing either is refused with JSON-RPC code -32602, as the protocol requires. |
Limits, and two things this endpoint does not do
What it spends is what the tool spends. Discovery and tools/list read nothing and cost
nothing. tools/call draws exactly what the route behind the named tool draws — a run each for
analyze and action, a step of the hourly configuration-write floor for a get_law that has to
fetch the law rather than read one already held, nothing for the other six — and the three
rate-limit headers come back on every answer the handler produces, the free calls included, with the
two exceptions named at the top of this page. Those headers report the run quota on every answer,
including a get_law that just spent the floor; the floor itself is readable only from
GET /v1/quotas.
GET and DELETE are 404, not 405. The specification suggests 405 on the MCP endpoint for
those verbs. This gateway answers 404 NOT_FOUND to every wrong verb on every route, so that a client
reading error.code gets an answer it can resolve; a client from an earlier revision fails on the one
as firmly as on the other.
There is no consent screen and no authorisation server. Authentication is the same bearer token as everywhere else — see Authentication. One-click connection from an assistant's own directory needs more than that, and it is not built yet.
curl https://api.granska.cloud/v1/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: server/discover" \
-d '{
"jsonrpc": "2.0",
"id": "discover-1",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'Send it without writing a client
If your organisation already has an account, an administrator can send this call from the API tester
at /admin/api-tester — the real gateway, with your own credentials. This is
the first route on the API that requires headers of its client, so the tester renders them as fields
of their own: MCP-Protocol-Version arrives filled in, since this server speaks one revision, and
Mcp-Method is yours to type — it is the method from the body beside it, server/discover in the
example the tester loads.