GRANSKA

Model Context Protocol

POSThttps://api.granska.cloud/v1/mcp

Speaks the Model Context Protocol, so an AI assistant can use this API without anybody writing code against it.

Bearer tokenSpends 1 run per call that starts an analysisAnswers with the rate-limit headers whenever the meter can be read

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 it discovers what is here for itself. What it presents is either a connection an administrator approved in the browser, which is how Codex and Claude Code connect, or a bearer token you obtained yourself with an API key.

It is the same gateway 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 eleven tools: get_legal_agreement, upload_url, analyze, get_job, delete_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. Before the route runs, the endpoint checks which arguments were sent, by name, and not what they hold: an argument the tool's inputSchema does not offer is refused with -32602 rather than passed on — which is how a tool that deliberately withholds one keeps it withheld — and so is a call that leaves out a required one. What an argument holds is the route's to judge, and its refusal comes back as a tool result with isError set — the two envelopes below shows one of each. The result comes back as a text block carrying the route's own JSON.

A JSON object comes back in machine-readable form as well. When the route answers an object, the result also carries it as structuredContent, so a client that reads structured results needs no parsing. The text block beside it is the same string it would be without it, so an assistant that reads only content sees no difference. An accepted analyze call comes back like this:

{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "{\n  \"success\": true,\n  \"jobId\": \"job_7d41c9\",\n  \"status\": \"QUEUED\"\n}"
      }
    ],
    "structuredContent": {
      "success": true,
      "jobId": "job_7d41c9",
      "status": "QUEUED"
    },
    "isError": false
  }
}

structuredContent is what the text block says and nothing more: the same answer from the same route. A route that answers anything other than an object, such as a list, is text only, and so is every result with isError set, which carries the route's refusal as a sentence. No tool publishes an outputSchema: the object is whatever the route answers, and its shape is described on that route's own page of this reference. An assistant on an earlier revision gets the same structuredContent beside the same text. Like the cut-short example further down, this one leaves out the _meta server-info block every answer carries.

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 seven 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. It is also where an assistant reads limits.maxWorkersPerProfile, how many workers dynamicContext.workerContext may name on analyze: that ceiling is the organisation's own, so the analyze schema states no number for it and points here instead.

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.

Request
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": {}
      }
    }
  }'
Response
{
  "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"
  }
}

Connecting Codex or Claude Code

An administrator connects an installed assistant without an API key. The assistant is given this endpoint's URL, opens a browser on GRANSKA's approval page, and is handed access of its own once the administrator approves. Nothing secret is pasted into the assistant: no client secret, no token and no password. Codex and Claude Code are the two assistants this is set up and tested for. Another client that implements the same MCP authorization standard may work, and is not promised to.

1. Add the server. One command, in a terminal on the computer where the assistant is installed. For Codex:

codex mcp add granska --url "https://api.granska.cloud/v1/mcp"

For Claude Code:

claude mcp add --transport http granska "https://api.granska.cloud/v1/mcp"

Claude Code adds the server for the project directory the command is run in. Add --scope user to have it in every project.

2. Approve in the browser. Codex opens the browser as soon as it has added the server; if no browser opened, codex mcp login granska starts the approval again. Claude Code opens it on claude mcp login granska, or when you choose the server under /mcp inside a session. Sign in to GRANSKA as you usually do, with email and password or with Google. The approval page names the assistant, the one organisation the connection reaches and what it allows, and you approve or deny. Only an administrator of that organisation can approve it. Anyone else is told so, and nothing is connected.

3. Know what you approved.

  • Full read and write access to the organisation's API. There is one level and no narrower one. The connection acts as the administrator who approved it, in that one organisation: it reaches every route of this reference the organisation is licensed for, changes included, and it spends the organisation's quota.
  • The tools are narrower than the access, on purpose. tools/list offers the eleven tools named above: the review workflow, with clinicalData withheld and the document taken only as an upload, so that no document or finished report travels through a model's context. The other routes are not offered as tools. They are part of what was approved all the same: a program holding the connection's access token can call them as ordinary HTTP, as this reference describes them.
  • A connection reads only the jobs it started. A job belongs to the connection that created it. The connection cannot read or delete a job started in the web application, by another connection or with an API key, and none of those can read the connection's.
  • It ends after 90 days. To continue, connect the assistant again.
  • The assistant's provider can receive what the API returns. The assistant is an external service: whatever a tool call or a route answers, a finished analysis with its findings and quotes included, can reach the provider, which keeps and uses it under its own terms and not ours.
  • Disconnect it in the web application, under Administer Organisation in the administration area, in the list of agent connections. Any administrator of the organisation can disconnect a connection, whoever approved it, and it is refused from its next request. Removing the server from the assistant, or codex mcp logout granska and claude mcp logout granska, makes the assistant forget its access but leaves the connection listed until it is disconnected or expires.

This URL is the organisation API's. GRANSKA's own platform administrators connect to a separate platform API, at another address and with an approval of its own. A connection never reaches both, and platform status approves nothing here: this endpoint asks for an administrator of the organisation.

An API key still works, and nothing about it has changed. A program that holds a client id and secret trades them for a bearer token as Authentication describes, and sends it to this endpoint as to any other.

The revision this server speaks

2026-07-28, which is the revision this page describes.

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.

Assistants on an earlier revision are answered too. Many assistants installed today, Codex among them, still open with the initialize handshake of 2025-11-25 or an earlier revision. A request that carries no _meta version and either opens with initialize or names 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 or 2024-10-07 in MCP-Protocol-Version is answered by that revision's handshake, statelessly: no Mcp-Session-Id is issued. It reaches the same tools, under the same token and the same quota. A refused tool call keeps its JSON-RPC code and message, and a spent run quota is still the gateway's 429. The one difference is the status line: these revisions carry a JSON-RPC error on HTTP 200.

A request whose _meta names any other revision, or whose MCP-Protocol-Version names a revision neither handshake speaks, is refused with 400 and JSON-RPC code -32022, and the error's data.supported lists the revision _meta may name:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": { "supported": ["2026-07-28"], "requested": "2025-11-25" }
  }
}

A _meta claim decides which revision a request is: one that makes it is held to 2026-07-28 whatever its headers say, so a request cannot mix the two.

ParameterDescription
MCP-Protocol-Version
"2026-07-28"·header·required
The protocol revision this request uses. Must equal the version in params._meta.A request with no params._meta that opens with initialize, or names 2025-11-25 or an earlier revision here, is answered by that revision's own stateless handshake instead. Any other revision is refused with JSON-RPC code -32022, whose data.supported lists the revision params._meta may name.
Mcp-Method
string·header·required
The body's method, mirrored into a header so gateways can route without parsing the body. Must equal it.
Mcp-Name
string·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"
  }
}

On this endpoint that 401 also carries a WWW-Authenticate: Bearer header naming where an assistant finds out how to be approved. The body and the status are the gateway's own, unchanged; what the header points at is at the end of this page.

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 refusals of its own, all -32602 and all decided before the route runs, so nothing is executed and no run is drawn. There are six, and the status beside each is this revision's — an earlier revision carries the same error on HTTP 200:

  • 400 — params.name is an empty string. On this revision a name that is missing or is not a string never gets this far: Mcp-Name has to mirror it, so the header check refuses the request first, with the -32020 above. An earlier revision has no such header, and there both are -32602 as well.
  • 404 — params.name names no tool tools/list reports.
  • 400 — params.arguments is present and is not an object.
  • 400 — an argument is sent that the tool's inputSchema does not list among its properties, one the tool withholds, such as clinicalData on action, included.
  • 400 — an argument the schema lists as required is not sent.
  • 400 — the schema says exactly one of several arguments must be sent (oneOf), and none or more than one is. No tool here publishes a oneOf today: action offers one of its route's two alternatives, so its schema simply requires jobId.

The last three are the whole check of the arguments, and it reads their names only. It is not a JSON Schema validation of the call. A type, an enum, a minLength, a minimum, and everything inside an argument that is an object or an array — its nested properties, items and additionalProperties — are published so that a model can form a correct call, and are not enforced at this level. A value that breaks one of them is passed to the route, which judges its own inputs exactly as it does over HTTP.

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. That is where a wrong value lands — so do not read the absence of a -32602 as the arguments having been accepted.

Two calls to analyze show the line. The first sends an argument the tool does not offer:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "analyze",
    "arguments": {
      "profileId": "lss_utredning",
      "jobId": "job_7d41c9",
      "priority": "high"
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

It is refused by name, with 400, and the route never runs:

{
  "jsonrpc": "2.0",
  "id": 3,
  "error": {
    "code": -32602,
    "message": "Invalid arguments for tool 'analyze': \"priority\" is not an argument of tool \"analyze\""
  }
}

The second sends only arguments the tool offers, with a value the published schema rules out two levels down — customRules is an array of sentences, and here it is one sentence:

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "analyze",
    "arguments": {
      "profileId": "lss_utredning",
      "jobId": "job_7d41c9",
      "dynamicContext": {
        "workerContext": {
          "worker_1": { "customRules": "Cite the statute behind every finding." }
        }
      }
    },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

Every name is one the tool offers, so the call reaches POST /v1/analyze, and the route refuses it with the 400 it answers over HTTP. That arrives as a 200 carrying a tool result:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "POST /v1/analyze refused this call with HTTP 400 BAD_REQUEST: Invalid dynamicContext: …"
      }
    ],
    "isError": true
  }
}

The route's own sentence follows where the example is cut short, and it names the field. analyze costs a run, so this second call drew one before the route ran and was given it straight back; the first drew nothing. The result also carries the _meta server-info block every answer has, left out here.

An assistant on an earlier revision gets the same two answers. The first is the same JSON-RPC error, code and message, on HTTP 200 rather than 400, which is how those revisions carry a refused tool call. The second is the same tool result with isError set, without resultType.

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.

ErrorWhen
401
UNAUTHORIZED
The Authorization header is missing, is not a readable bearer token, or names no tenant.
401
TOKEN_EXPIRED
The access token was issued by this gateway and has since expired. Not probed: it needs a token older than its own lifetime.
429
TOO_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.
500
INTERNAL_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 in params._meta.
  • Mcp-Method — must equal the body's method.
  • Mcp-Name — only on tools/call, resources/read and prompts/get, where it mirrors params.name or params.uri. Required on tools/call, where it must equal the tool name in params.name, and refused on server/discover and tools/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_legal_agreement",
        "description": "Reads the upload agreement every uploaded document is submitted under: its current version, where it is read, and whether your organisation has accepted it. Read only: an administrator accepts it for the organisation in the GRANSKA app.\n\nCall this before upload_url. When organisationRequired is true and organisationAccepted is false, an administrator of the organisation must accept the agreement in the GRANSKA app at agreementUrl first; no tool here can accept it, so tell the person and stop.\n\nCalls GET /v1/legal-agreement.",
        "inputSchema": {
          "type": "object",
          "properties": {},
          "additionalProperties": false
        }
      },
      {
        "name": "upload_url",
        "description": "Issues a job id and a pre-signed URL to upload a PDF to: the one way to give POST /v1/analyze its document.\n\nThe document is never sent through this conversation: it is far too large, and it does not belong in a model's context. Call upload_url first, have whatever holds the file PUT it to the returned URL, then call analyze with the jobId that came back. Send uploadAttestation to upload_url, with the agreementVersion get_legal_agreement names, only once the person you act for has confirmed that they may submit this document; analyze takes no attestation of its own.\n\nCalls POST /v1/upload-url.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "uploadAttestation": {
              "type": "object",
              "properties": {
                "agreementVersion": {
                  "type": "string",
                  "pattern": "^legal:[1-9][0-9]*$",
                  "description": "The agreement version GET /v1/legal-agreement names as current."
                },
                "uploadAuthorised": {
                  "type": "boolean",
                  "enum": [
                    true
                  ],
                  "description": "Always true: your confirmation that you may submit this document. false is refused."
                }
              },
              "required": [
                "agreementVersion",
                "uploadAuthorised"
              ],
              "additionalProperties": false,
              "description": "Your confirmation, for this one document, that you are authorised to submit it under the current upload agreement. Send it only once the person or system you act for has confirmed it. Required once the agreement is enforced, together with your organisation's acceptance, which an administrator gives in the GRANSKA app."
            }
          },
          "additionalProperties": false
        }
      },
      {
        "name": "analyze",
        "description": "Queues an analysis of one document and answers immediately with a job id. Consumes one run from the tenant's quota.\n\nThe document is never sent through this conversation: it is far too large, and it does not belong in a model's context. Call upload_url first, have whatever holds the file PUT it to the returned URL, then call analyze with the jobId that came back. Send uploadAttestation to upload_url, with the agreementVersion get_legal_agreement names, only once the person you act for has confirmed that they may submit this document; analyze takes no attestation of its own. The analysis runs asynchronously: this returns a jobId to poll with get_job. Before sending dynamicContext.workerContext, read limits.maxWorkersPerProfile from get_quotas: it is how many workers this tenant may target in one call.\n\nCalls POST /v1/analyze.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "profileId": {
              "type": "string",
              "minLength": 1,
              "description": "Which analysis profile to run. GET /v1/profiles lists the ones this tenant is licensed for."
            },
            "jobId": {
              "type": "string",
              "minLength": 1,
              "description": "The job id POST /v1/upload-url returned, after the document was PUT to the upload URL that came with it. This is the only way to give an analysis its document: pdfBase64 and pdfUrl are no longer accepted, and a request that carries either is refused with 400 before anything else is read or a run is spent. It starts one job: sent again once a job stands under it, the request is refused with 409 CONFLICT."
            },
            "includeDiagnostics": {
              "type": "boolean",
              "description": "Adds each reviewer's own output and every consolidation step's output to the finished job under result.diagnostics. The consolidation half is result.diagnostics.reducers, one entry per review keyed by its profileId — one entry for an ordinary run, one per member for a review package. The older result.diagnostics.reducer holds the same record for an ordinary run and is deprecated; it is null for a review package, which has one consolidation per review and no single answer to give."
            },
            "dynamicContext": {
              "type": "object",
              "properties": {
                "entityId": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 200,
                  "description": "Your own label for whoever the document is about. Accepted and validated, and then read by nothing: it is not stored on the job and no endpoint publishes it, so it cannot serve as a correlation key — hold your own mapping against the jobId this call returns. Not an identifier from this API — never a profileId, a jobId or a snippetKey. At most 200 characters, counted as Unicode code points; a longer value is refused with 400."
                },
                "entityName": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 200,
                  "description": "Your own display name for whoever the document is about. It is written into the user prompt of every worker you name in workerContext, as client-supplied context — so it reaches the language model rather than being an inert label. Workers you do not name in workerContext never see it, nothing stores it, and no endpoint publishes it back. At most 200 characters, counted as Unicode code points; a longer value is refused with 400."
                },
                "workerContext": {
                  "type": "object",
                  "description": "Keyed by worker id. Each value adds rules or legal sources to that one worker. Every key must name a worker this analysis runs — after requestedWorkerIds has narrowed the profile — and no two keys may name the same worker; otherwise the request is refused with 400 before any job is created. How many workers it may name is this tenant's own ceiling and differs between tenants: GET /v1/quotas reports it as limits.maxWorkersPerProfile, and one key more is refused with 400.",
                  "additionalProperties": {
                    "type": "object",
                    "properties": {
                      "customRules": {
                        "type": "array",
                        "items": {
                          "type": "string",
                          "minLength": 1,
                          "maxLength": 1000
                        },
                        "maxItems": 10,
                        "description": "Extra instructions for this one worker, in plain sentences. Free text, not identifiers. At most 10 rules for one worker, each at most 1000 characters, counted as Unicode code points; one rule more, or one rule longer, and the request is refused with 400."
                      },
                      "snippetIds": {
                        "type": "array",
                        "items": {
                          "type": "string",
                          "minLength": 1
                        },
                        "maxItems": 10,
                        "description": "The snippetKey each authored legal source was minted under — the value GET /v1/snippets publishes for every source this tenant may use, and the same value POST /v1/snippets returns when a source is created. Never the document id: a document id is a well-formed string, so it is accepted, the job answers 200, and it resolves to nothing without a word. These sources are added to the worker on top of its own rules and the snippet keys already stored on it, so a key that resolves to nothing does not empty its legal framework — it drops exactly the extra source you were trying to add, and the finished job does not say it is missing. At most 10 keys for one worker; one key more and the request is refused with 400."
                      }
                    },
                    "additionalProperties": false
                  }
                }
              },
              "additionalProperties": false,
              "description": "Supplementary legal context merged into the targeted workers' prompts for this one job."
            },
            "webhookUrl": {
              "type": "string",
              "description": "Called when the job finishes, instead of polling GET /v1/jobs/:jobId."
            },
            "webhookSecret": {
              "type": "string",
              "description": "Signs the webhook call with HMAC-SHA256 so the receiver can verify it came from here."
            }
          },
          "additionalProperties": false,
          "required": [
            "profileId",
            "jobId"
          ]
        }
      },
      {
        "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. Reading does not delete the result; the retention sweep removes a finished job between 15 and 30 minutes after its last update, and delete_job removes it at once.\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."
            },
            "includeTrace": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ],
              "description": "Set to \"true\" to add `result.analysisTrace` to a completed analysis: the run's legal trace, or `null` when the run has none that is valid. It adds nothing to an action, or to a job that has no result yet. Any other value, or the parameter sent twice, answers 400."
            }
          },
          "additionalProperties": false,
          "required": [
            "jobId"
          ]
        }
      },
      {
        "name": "delete_job",
        "description": "Deletes a finished job before it would expire on its own.\n\nCall this once get_job has answered COMPLETED or FAILED and you have what you need from the result. It deletes the job and its result at once; a job that is still QUEUED or IN_PROGRESS answers 409, so wait and try again.\n\nCalls DELETE /v1/jobs/:jobId.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "jobId": {
              "type": "string",
              "minLength": 1,
              "description": "The id of a job that has reached COMPLETED or FAILED."
            }
          },
          "additionalProperties": false,
          "required": [
            "jobId"
          ]
        }
      },
      {
        "name": "get_profiles",
        "description": "Lists the analysis profiles this tenant is licensed for — who owns each one, whether it is published, and the workers it runs.\n\nCall this before analyze: it names the profiles this organisation may run.\n\nCalls GET /v1/profiles.",
        "inputSchema": {
          "type": "object",
          "properties": {},
          "additionalProperties": false
        }
      },
      {
        "name": "action",
        "description": "Queues a follow-up action over a finished analysis and answers immediately with a job id. Consumes one run from the tenant's quota.\n\nRuns over a finished analysis and returns a new jobId to poll with get_job.\n\nNot available through this tool: clinicalData.\n\nCalls POST /v1/action.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "jobId": {
              "type": "string",
              "minLength": 1,
              "description": "The analysis to act on, named rather than pasted: the id POST /v1/analyze returned. The gateway reads the finished analysis off that job itself. Send this or clinicalData, never both. include_in_action cannot be used with this form — it is a mark on the analysis you send, and this form sends none, so the action is written from every finding."
            },
            "actionType": {
              "type": "string",
              "minLength": 1,
              "description": "Which action to run. GET /v1/actions lists the ones this tenant is licensed for."
            },
            "profileId": {
              "type": "string",
              "minLength": 1,
              "description": "The profile the analysis was produced with — the same id you passed to POST /v1/analyze, a granskningspaket included. Omitting it is a 400. Name a bundle and it is expanded into its member profiles: the action is one document written from the union of both members' law, and the findings you send back must carry profile_id tags naming that bundle's own members and nothing else."
            },
            "includeDiagnostics": {
              "type": "boolean",
              "description": "Adds the action job's diagnostic output to the finished job."
            },
            "webhookUrl": {
              "type": "string",
              "description": "Called when the job finishes, instead of polling GET /v1/jobs/:jobId."
            },
            "webhookSecret": {
              "type": "string",
              "description": "Signs the webhook call with HMAC-SHA256 so the receiver can verify it came from here."
            }
          },
          "additionalProperties": false,
          "required": [
            "actionType",
            "profileId",
            "jobId"
          ]
        }
      },
      {
        "name": "search_laws",
        "description": "Searches a jurisdiction's catalogue of laws, and says which of them this corpus holds.\n\nFinds the work identifier get_law and get_snippets need. When unavailableSources is not empty, a law source was down and the list is missing its hits: say so rather than presenting the list as complete.\n\nCalls GET /v1/laws.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "jurisdiction": {
              "type": "string",
              "minLength": 1,
              "description": "Which legal order to search, as a code such as SE. Only a legal order with an integrated law source can be searched — SE, NO, DK and US_FED resolve today; anything else is refused by name."
            },
            "query": {
              "type": "string",
              "minLength": 2,
              "description": "Free text matched against each law's number and title. At least two characters."
            },
            "limit": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "description": "How many hits to return, between 1 and 50. Defaults to 50."
            }
          },
          "additionalProperties": false,
          "required": [
            "jurisdiction",
            "query"
          ]
        }
      },
      {
        "name": "get_law",
        "description": "Reads one law's provisions, each with the pinpoint a profile must cite it by.\n\nCalls GET /v1/laws/:jurisdiction/:work.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "jurisdiction": {
              "type": "string",
              "minLength": 1,
              "description": "Which legal order the law belongs to, as a code such as SE. Only a legal order with an integrated law source resolves — SE, NO, DK and US_FED resolve today; the refusal names the ones that do."
            },
            "work": {
              "type": "string",
              "minLength": 1,
              "description": "Which law, exactly as GET /v1/laws spells it. Opaque — never parsed, and never assembled with the pinpoint into one string."
            },
            "cursor": {
              "type": "string",
              "minLength": 1,
              "description": "The nextCursor a previous response carried, to read the page after it. Omit for the first page. Opaque — send it back exactly as received, never build one. Paging costs nothing: only the first call can fetch the law."
            }
          },
          "additionalProperties": false,
          "required": [
            "jurisdiction",
            "work"
          ]
        }
      },
      {
        "name": "get_snippets",
        "description": "Lists the legal sources this tenant may use, or resolves exactly one of them.\n\nUse this to read the legal text behind a finding the analysis reported.\n\nCalls GET /v1/snippets.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "includeText": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ],
              "description": "Set to \"true\" to get each source's full text rather than metadata alone."
            },
            "snippetKey": {
              "type": "string",
              "minLength": 1,
              "description": "Resolves the one authored source with this key instead of listing. Cannot be combined with the reference fields."
            },
            "jurisdiction": {
              "type": "string",
              "minLength": 1,
              "description": "Which legal order the provision belongs to — a code such as SE or US_NH, matched exactly. Given together with work and pinpoint, resolves one statute provision."
            },
            "work": {
              "type": "string",
              "minLength": 1,
              "description": "Which law. Given together with jurisdiction and pinpoint, resolves one statute provision."
            },
            "pinpoint": {
              "type": "string",
              "minLength": 1,
              "description": "Where in the law — the id the provision is addressed by (\"par_7§\"), never the label it is printed as (\"7 §\"). Given together with jurisdiction and work, resolves one statute provision."
            }
          },
          "additionalProperties": false
        }
      },
      {
        "name": "get_quotas",
        "description": "Reads this tenant's run limit, what it has spent this period, when the period resets, and how many workers one analysis may target.\n\nanalyze and action are the tools that spend a run, and get_law can tick the hourly configuration-write floor. Call this first to see whether the next one is affordable; it draws nothing itself.\n\nCalls GET /v1/quotas.",
        "inputSchema": {
          "type": "object",
          "properties": {},
          "additionalProperties": false
        }
      }
    ],
    "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.

Three things the descriptions say out loud, because an assistant gets each 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, the only document argument it takes. A pinpoint is the id a provision is addressed by, par_7§, never the label it is printed as, 7 §. And reading a result does not delete it: get_job leaves a finished job in place until the retention sweep removes it, between 15 and 30 minutes after its last update, while delete_job removes the job and its result at once — once get_job has answered COMPLETED or FAILED, since a job still running is refused with 409.

A result you read enters the assistant's context. Once get_job has returned a finished analysis, its findings and quotes are in the conversation, and from then on they are kept under your assistant provider's terms, not ours. delete_job removes our copy; it cannot remove theirs.

ParameterDescription
jsonrpc
"2.0"·body·required
The JSON-RPC version. Always the string 2.0.
id
string | 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.
method
string·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. Before the route runs, only the argument names are checked against the tool's own inputSchema: an argument that schema does not offer is refused with -32602 rather than passed on, and so is a call that leaves out a required one. The values are validated by the route, whose refusal comes back as a tool result with isError set rather than as -32602.
params
object·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 one thing 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 seven — 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. An assistant on an earlier revision that asks for the optional GET stream is told 404 and carries on without it: nothing here is ever sent on that stream.

Request
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": {}
      }
    }
  }'

How an assistant finds the approval

An MCP client library does all of this for you. It is written down for whoever is wiring one up by hand, or reading a trace of one.

The endpoint says where to go. A request to this endpoint with no credential, or with one that is refused, is answered 401 with WWW-Authenticate: Bearer, carrying resource_metadata and scope, and error="invalid_token" when a bearer was sent. resource_metadata is this endpoint's protected-resource document (RFC 9728) at https://api.granska.cloud/.well-known/oauth-protected-resource/v1/mcp. It names the resource, which is this URL exactly, its one scope, customer.full, and its authorisation server, https://api.granska.cloud, whose own metadata (RFC 8414) is at /.well-known/oauth-authorization-server.

The client registers itself and holds no secret. Registration is dynamic (RFC 7591) and public: a client states a name and its exact redirect URIs, https or http on a loopback address, and is given a client_id and nothing else. Client ID Metadata Documents are not supported. The name is the client's own claim, which is why the approval page shows it as unverified.

Approval is the authorisation-code grant with PKCE S256. The authorisation endpoint holds the request for ten minutes and sends the browser to the approval page. An approved request returns a single-use code, valid for five minutes, with the issuer in iss.

What the assistant is handed is short-lived and bound to this URL. An access token lasts 15 minutes and is refused anywhere but the resource it was approved for. A refresh token is exchanged once and replaced on every use, and a refresh token presented a second time ends the connection. The connection as a whole ends 90 days after it was approved, however often it was refreshed. Every request and every refresh is checked against the connection as it stands, so a disconnect, or the approving administrator losing the role or the account, refuses the next one.

These endpoints are not POST /v1/oauth/token. That route is the client-credentials exchange for an API key and is unchanged. An approved assistant's tokens come from the authorisation server's own token endpoint, which its metadata names.

There is no listing in an assistant's own directory. Connecting is the URL above, added by hand.

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 with the revision this page describes, and Mcp-Method is yours to type — it is the method from the body beside it, server/discover in the example the tester loads.