MCP for agents
Nija speaks the Model Context Protocol, so an agent host can call Nija models as tools. The server is at https://nija.africa/mcp and speaks protocol version 2026-07-28 over Streamable HTTP: one POST carries one JSON-RPC 2.0 message. There is no event-stream half, so a GET is a 404, and a JSON-RPC batch (a top-level array) is refused rather than half-served.
The surface originates nothing. A tool call re-enters the same completion path, the same pricing, the same spend cap and the same ledger a POST /v1/chat/completions call uses, so an agent is bounded by the key you give it exactly as your own code is.
Authenticate
Present an ordinary inference key (sk-nija-...) as Authorization: Bearer. There is no MCP-specific credential, registration or connector record to create, and no session: the transport is stateless, so the key is re-resolved on every message. A missing, invalid, disabled or expired key is refused 401 by the same path and with the same error envelope that refuses it on /v1/chat/completions. A management key is welcome on everything that does not spend and is refused at the billable tools with provisioning_key_not_permitted. Create keys under Keys. Your own endpoint and a pre-filled configuration are on Connect an agent.
First call
Set NIJA_KEY to your key and run this. It spends nothing.
curl https://nija.africa/mcp \
-H "Authorization: Bearer $NIJA_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover"}'Client configuration
Most agent hosts take the first shape. The second is for a host that can prompt for a secret, and is the one to prefer when the file lands in a repository: it holds a reference, not the key.
{
"mcpServers": {
"nija": {
"type": "http",
"url": "https://nija.africa/mcp",
"headers": {
"Authorization": "Bearer sk-nija-..."
}
}
}
}
{
"servers": {
"nija": {
"type": "http",
"url": "https://nija.africa/mcp",
"headers": {
"Authorization": "Bearer ${input:nija-key}"
}
}
},
"inputs": [
{
"id": "nija-key",
"type": "promptString",
"description": "Nija inference key",
"password": true
}
]
}
Methods
| Method | What it does |
|---|---|
server/discover | Supported versions and capabilities. Optional to call. |
tools/list | The callable tools and their JSON Schema arguments. |
tools/call | Run a tool. The only method that can spend. |
prompts/list | The account holder's own saved presets, as MCP prompts. |
prompts/get | One preset, rendered as prompt messages. |
resources/list | The read-only context documents below. |
resources/read | Read one of them by URI. |
tasks/get | Poll a long-running call. The answer rides on this same reply once it is `completed`. |
tasks/cancel | Ask a running task to stop. |
Version negotiation is per request, not per session: declare yours under params._meta["io.modelcontextprotocol/protocolVersion"]. Omitting it is legal and is served at the server's current version, which every reply echoes back in result._meta. A version that is present but unsupported is refused with error.data.name = UnsupportedProtocolVersionError and the supported list in error.data.supportedVersions, so a client can retry on a version you both speak.
Discover the surface
Optional to call. It answers identically for any authenticated caller and spends nothing, so the `cacheScope: "public"` hint invites your client to keep it for the hour `ttlMs` states rather than re-fetching on every connector refresh.
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover"
}Reply:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": [
"2026-07-28"
],
"capabilities": {
"tools": {
"listChanged": false
},
"prompts": {
"listChanged": false
},
"resources": {
"listChanged": false
},
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
},
"ttlMs": 3600000,
"cacheScope": "public",
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "nija",
"title": "Nija",
"version": "1.0.0"
},
"io.modelcontextprotocol/protocolVersion": "2026-07-28"
}
}
}Tools
| Tool | Spends | What it does |
|---|---|---|
nija_ask | yes | Run a chat completion on any catalogue model and return the answer inline. |
nija_list_models | no | Filter the model catalogue by price, context, modality, region, language or search. |
nija_deep_think | yes | Hand a hard problem to a strong model and collect the answer through the Tasks methods. |
tools/list returns each tool's JSON Schema, so a calling model plans against the real argument shape rather than against this page. Unknown fields on nija_ask are forwarded to the provider verbatim; nija_list_models declares its filters closed, so an unrecognised filter is a refusal rather than a filter that silently does nothing.
nija_ask: one question, answered inline
Billed to the prepaid wallet at the model rate. `max_tokens` is the ceiling the wallet is reserved against, so a smaller value costs less.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "nija_ask",
"arguments": {
"model": "openai/gpt-4o-mini",
"prompt": "Habari. Reply in one short sentence.",
"max_tokens": 64
}
}
}On success the result carries the answer as a text block and these fields in structuredContent:
| Field | What it is |
|---|---|
answer | The generated text, repeated from the `content` block. |
model | The model that actually ran, after any preset or routing resolution. |
request_id | Reconciles against `GET /v1/generation?id=` and the ledger. |
price_local | What this call cost, in `currency`. |
currency | The account's billing currency. |
residency_met | Whether the residency requirement was satisfied. |
usage | The OpenAI-shaped prompt / completion / total token counts. |
nija_list_models: choose before you spend
Spends nothing, and a management key may call it. The catalogue runs to hundreds of models, so filter rather than list it whole.
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "nija_list_models",
"arguments": {
"max_price": 0.000002,
"min_context": 100000,
"sort": "price"
}
}
}Long-running calls
nija_deep_think runs under the io.modelcontextprotocol/tasks extension, which server/discover advertises under capabilities.extensions. Declare it in the clientCapabilities you send with every call: this tool has no inline answer to fall back on, so a call without the declaration is refused with -32021 rather than handed a shape you cannot read. The tools/call reply is then a handle, { resultType: "task", taskId, status, createdAt, lastUpdatedAt, ttlMs, pollIntervalMs, statusMessage }, not an answer. It is billed to the same wallet as nija_ask, exactly once, when the work finishes.
nija_deep_think: hand over a hard problem
Answers with a task handle immediately, never inline, so a call that runs for minutes cannot time out. Declare the `io.modelcontextprotocol/tasks` extension on the call: without it there is no result shape this tool can return, and it refuses with `-32021` rather than handing you a handle you cannot read. The reply is a `CreateTaskResult` -- `resultType: "task"` with `taskId`, `status`, `ttlMs` and `pollIntervalMs` flat beside it.
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "nija_deep_think",
"arguments": {
"model": "openai/gpt-4o-mini",
"prompt": "Plan a three-stage rollout for a payments migration.",
"effort": "high",
"max_tokens": 4096
},
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}Poll the handle, and read the answer off the same reply
Wait `pollIntervalMs` milliseconds between polls. The status moves to `completed`, `failed` or `cancelled` exactly once -- and a `completed` poll already carries the tool result in `result`, so there is nothing further to fetch. A call the wallet or the model refused still settles `completed`, with the refusal inside that result; `failed` means the task itself errored and carries a JSON-RPC `error` instead. Every task request must carry the extension declaration too.
{
"jsonrpc": "2.0",
"id": 5,
"method": "tasks/get",
"params": {
"taskId": "<taskId>",
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}tasks/cancel takes the same taskId and answers an empty acknowledgement: cancellation is cooperative, so read the settled state from your next tasks/get rather than from the ack. There is no tasks/list: a handle you lose is re-submitted, not enumerated. There is no tasks/update either, because nothing here ever asks you a question mid-task. A handle is scoped to the account that minted it, so another account's taskId is simply not found. If the process running a task dies, the task resolves as failed rather than resuming: your credential is never persisted, so there is nothing to resume it with.
Prompts: your saved presets
The prompts primitive serves the calling account's own saved presets (managed under Presets). Discover a slug with prompts/list, read its messages with prompts/get, or spend it by passing the slug as nija_ask's preset argument, where it fills the model, system instruction and generation parameters the call did not set. Anything the call sets explicitly wins.
List the saved presets
Returns only the calling account's own presets. Spend one by passing its slug as `nija_ask`'s `preset` argument; anything the call sets explicitly wins over the preset.
{
"jsonrpc": "2.0",
"id": 8,
"method": "prompts/list"
}Resources
Read-only context documents, all free to read. Reading the balance before an expensive task is how an agent chooses a cheaper model instead of discovering the wall by hitting it.
| URI | What it is |
|---|---|
nija://account/balance | Prepaid credit balance, currency and lifetime usage. |
nija://account/spend-limit | The cap on the presented credential and the allowance left in its rolling window. |
nija://catalogue/models | The whole model catalogue, unfiltered. |
Read the wallet before an expensive task
Reading costs nothing. The balance moves as tools spend, so re-read it rather than caching it.
{
"jsonrpc": "2.0",
"id": 7,
"method": "resources/read",
"params": {
"uri": "nija://account/balance"
}
}Refusals
A tool that is refused answers with an isError RESULT, never a JSON-RPC error, so the calling model reads the refusal and can adapt. structuredContent.reason is a stable code you can branch on and retryable answers the only question that matters in the moment: will this same call work later, unchanged. The original Nija error envelope rides back verbatim under error.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "Not enough credits to process this request\nReason: insufficient_balance (retryable: false).\nThe prepaid wallet cannot cover this call. Retrying will fail identically until credits are added, so stop and surface the top-up link in `error.top_up_url` to the account holder. A smaller `max_tokens` or a cheaper model lowers what a call reserves."
}
],
"isError": true,
"structuredContent": {
"reason": "insufficient_balance",
"message": "Not enough credits to process this request",
"retryable": false,
"next_action": "The prepaid wallet cannot cover this call. Retrying will fail identically until credits are added, so stop and surface the top-up link in `error.top_up_url` to the account holder. A smaller `max_tokens` or a cheaper model lowers what a call reserves.",
"error": {
"type": "insufficient_balance",
"code": 402,
"message": "Not enough credits to process this request",
"localized_message": "errors.insufficient_balance",
"balance_local": 0.12,
"currency": "USD",
"estimated_cost": 0.4,
"top_up_url": "https://nija.africa/credits?required=0.4",
"top_up_cta": "cta.top_up_now"
}
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28"
}
}
}| reason | retryable | What to do |
|---|---|---|
insufficient_balance | no | The wallet cannot cover the call. Top up; `error.top_up_url` carries the link. |
spend_cap_reached | no | The key hit its own cap. The window is rolling, so headroom returns gradually, not at a reset instant. |
provisioning_key_not_permitted | no | A management key reached a billable tool. Present an inference key instead. |
model_unknown | no | The slug is not in the catalogue and the provider rejected it too. Re-pick from the list. |
model_unavailable | no | The model exists but cannot be served for this account or request. |
preset_unknown | no | No saved preset holds that slug. Nothing was spent. Re-read `prompts/list`. |
residency_unavailable | no | No provider can serve the model inside the required region. Relax it or pick another model. |
rate_limited | yes | Slow down. `retry_after_seconds` rides along when the response carried a Retry-After. |
invalid_request | no | Rejected before anything was spent. Fix the arguments against the tool schema. |
authentication_failed | no | Rare: only a key that dies mid-call reaches this code. One already dead when the call arrives is refused as described below instead. Either way a new key is needed. |
forbidden | no | Refused by an upstream decline or an account-level block. |
upstream_error | yes | The provider faulted. Retrying can work. |
no_provider | yes | Every provider for the model is down. Retry, or send a `models[]` fallback list. |
internal_error | yes | A Nija-side fault. Retrying can work. |
A credential that is already dead when the call arrives never reaches that table. `POST /mcp` resolves the key before it dispatches anything, so a revoked, disabled or expired key is refused at the door with HTTP 401 and the shared Nija auth envelope: `error.type` is `authentication_error`, and there is no reason code, no `structuredContent` and no `retryable` flag to read. That is the auth branch to write, and it is final -- retrying it cannot help. `authentication_failed` reaches the wire only when a key dies between that check and the billed call behind it.
A protocol-level fault is different: a body that is not JSON at all is -32700 at HTTP 400, a well-formed body that is not a JSON-RPC message is -32600, and an unknown method is -32601 carrying the real method list in error.data.supportedMethods. A well-formed request whose handling fails is an HTTP 200 with a JSON-RPC error, which is how a JSON-RPC client is built to read it.
Not implemented
samplingloggingare deprecated at protocol 2026-07-28 and are deliberately not implemented, so server/discover advertises neither. Nija advertises only what it serves: a capability in that document has a working method behind it.
Watching the spend
Agent traffic is tagged as its own channel, so it is attributable in the surfaces you already use: Activity carries a channel column, a channel filter and a CSV export, and Spend breaks the period down by channel. Cap the key itself under Keys if you want a hard ceiling on what an agent can spend.