MCP Server

Method: GET | POST | DELETE /{api_prefix}/_mcp Auth: Flux API Key — same permission model as the REST endpoints: read covers the read tools; the write tools additionally require create/update grants on the key's role. Anonymous traffic is accepted on public APIs (read tools only).

Each Flux API prefix exposes an embedded Model Context Protocol server over Streamable HTTP. The server publishes five read tools — discovery, schema, single fetch, list, and search — that are always present, plus two opt-in write tools (create_record, update_record) that appear only when the API key grants them. Everything is backed by the same data and permissions as the REST endpoints. It is the concrete implementation of the Agent-Native Knowledge API — the auto-generated MCP surface every Flux API ships with.


URL Pattern

{GET|POST|DELETE} https://{environment_key}.fxns.io/{api_prefix}/_mcp

Example:

POST https://7c9h4pwu.fxns.io/blog/_mcp

The MCP endpoint is per-API: every prefix gets its own catalog, scoped to that prefix's connected collections. There is no global /_mcp.


Transport

  • Protocol: MCP Streamable HTTP 2025-03-26.
  • Wire format: JSON-RPC 2.0 over POST with Content-Type: application/json. Both single-message and batched-array payloads are accepted.
  • GET /{api_prefix}/_mcp returns a health probe: {"status":"ok","protocol":"mcp-streamable-http","api":"..."}.
  • DELETE /{api_prefix}/_mcp closes the session referenced by Mcp-Session-Id and always returns HTTP 204 (idempotent).
  • All JSON-RPC errors are returned inside the response body — HTTP status stays 200 on successful transport, regardless of any JSON-RPC error inside.

Server identification

The initialize response carries:

{
  "protocolVersion": "2025-03-26",
  "capabilities": {"tools": {}},
  "serverInfo": {"name": "foxnose-flux-mcp", "version": "0.1.0"}
}

Sessions

The server is stateful per logical conversation. A session lifecycle:

  1. Client POSTs initialize. The response sets the Mcp-Session-Id header to a server-generated UUID.
  2. Every subsequent POST and DELETE must echo that UUID in the same header.
  3. Sessions expire after 1 hour of inactivity; every valid touch refreshes the TTL.
  4. A DELETE /{api_prefix}/_mcp with the session id removes it explicitly.

Without a valid session, every JSON-RPC method other than initialize returns:

{
  "jsonrpc": "2.0",
  "id": ...,
  "error": {
    "code": -32002,
    "message": "Server not initialized",
    "data": {"hint": "Call initialize first and reuse Mcp-Session-Id header."}
  }
}

Batched calls — initialize plus other methods in a single POST array — work the same way; the server issues the id at the start of the batch and applies it to all subsequent messages in that batch.


Rate limiting

The initialize method is rate-limited per client IP using a fixed 60-second window. The default cap is 60 initializations per minute per IP. Other methods (tools/list, tools/call, ping) are not rate-limited at this layer.

Client IP is resolved in this order: CF-Connecting-IP header → first hop of X-Forwarded-For → request socket address.

When the cap is exceeded, the server responds with:

{
  "jsonrpc": "2.0",
  "id": ...,
  "error": {
    "code": -32000,
    "message": "Too many initialize calls",
    "data": {"hint": "Slow down; sessions are rate-limited per client IP."}
  }
}

HTTP status remains 200.


JSON-RPC Methods

  • Name
    initialize
    Type
    method
    Description

    Negotiates protocol version and issues a session. No params required.

  • Name
    ping
    Type
    method
    Description

    Health probe inside the session. Returns an empty result object.

  • Name
    tools/list
    Type
    method
    Description

    Returns the tool catalog with input JSON schemas. Read tools are always listed; write tools appear only when the supplied key grants them (the list is advisory — tools/call re-checks permissions regardless).

  • Name
    tools/call
    Type
    method
    Description

    Invokes a tool. Params: { name: string, arguments: object }. The result is wrapped in an MCP tool envelope (see Tool results).

Notifications (JSON-RPC messages without an id) return HTTP 202 with no body. Unknown methods return JSON-RPC error -32601 "Method not found".


Tool Catalog

Five read tools (always present) and two write tools (opt-in, see Write tools). The exact names, descriptions, and input schemas are returned by tools/list. Names below match params.name for tools/call.

  • Name
    discover_resources
    Type
    tool
    Description

    List available Flux resources and capabilities for this API prefix. Input: {}. Output (structuredContent): { api, resources: [...], usage_rules: [...] } where each resource carries resource_id, title, path_template, required_parents, capabilities (subset of get_one, get_many, search), writable (subset of create, update; empty or absent for read-only collections), description, schema_ref.

  • Name
    describe_resource
    Type
    tool
    Description

    Return the JSON schema and searchability for a resource. Input: { resource_id, parents? }. Output mirrors Schema Introspectionjson_schema, searchable_fields, non_searchable_fields, actions, path, etc. The json_schema is self-contained: nested-Component fields appear as {"$ref": "#/$defs/<component>"} with every referenced Component inlined in an embedded $defs block, so an agent can consume the schema without resolving any external references.

  • Name
    get_record
    Type
    tool
    Description

    Fetch one record by id. Input: { resource_id, record_id, parents?, locales?, populate? }.

  • Name
    query_records
    Type
    tool
    Description

    List records by filters / sort / cursor. Input: { resource_id, parents?, filters?, sort?, limit?, cursor?, locales?, populate? }. limit defaults to 5 and accepts up to 100; both are declared in the tool's JSON schema, so a schema-reading model sees them without spending a call. See Page size. Filters use { field, op, value } triplets. Supported op values: eq, ieq, ne, not_eq, gt, gte, lt, lte, in, not_in, contains, icontains, startswith, istartswith, endswith, iendswith, between, includes, iincludes.

  • Name
    search_records
    Type
    tool
    Description

    Full search by text and/or vector. Input: { resource_id, query, search_type?, parents?, filters?, limit?, cursor?, locales?, populate? }. search_type is one of text, semantic, hybrid, vector_boosted. limit defaults to 5 and accepts up to 100 — see Page size.

Write tools

Two write tools let an agent create and update records through the same MCP connector it reads from — the natural fit for agent-memory setups where the agent saves what it learns.

  • Name
    create_record
    Type
    tool
    Description

    Create a record in a collection and publish it immediately. Input: { collection, data, key?, parents? }. data must match the collection schema (inspect it with describe_resource); the optional key is an external identifier for deduplication — creating a second record with the same key returns an external_id_conflict error instead of a duplicate. Output: { resource_key, revision_key, write_units, published: true }.

  • Name
    update_record
    Type
    tool
    Description

    Replace a record's document with a new published revision. Input: { collection, record_id, data, parents?, expected_revision? }. This is a full-document replace, not a partial patch — send the complete document. expected_revision makes the write conditional. Output: { resource_key, revision_key, write_units, published: true }.

There is deliberately no delete tool.

The same writes are available over plain REST — POST / PUT on the collection routes — with identical semantics, permissions, and billing; MCP and REST are two transports over one write pipeline.

Every write runs the full publish pipeline — schema validation, a new revision, vectorization — exactly as a Management API write would. Invalid data returns a content_validation_failed tool error carrying the standard content validation payload (errors[] with json_path, capped at 100 items) so the agent can fix the document and retry.

Conditional updates

Two agents on one collection, each computing an update from what it read, is the ordinary case rather than a rare race — and left alone, the second write silently supersedes the first. Pass expected_revision to update_record and the write is applied only if the record is still at that revision. The value is the _sys.revision field of a record the agent read; create_record has no equivalent, because a record being created has no current revision to name.

Four tool errors come out of it, and they call for different recoveries:

  • precondition_failed — the record moved on. The message names the revision that is current now, and details relays the underlying response, so the value is also at details.detail.current_revision. Re-read with get_record, recompute against what you read, then retry with the new _sys.revision. Retrying the same document would overwrite whatever moved it.
  • revision_number_conflict — two writers collided on the next revision number; nothing was lost. Retry the same call.
  • invalid_expected_revision — the value is not a revision key. The hint says where to get one.
  • conditional_write_disabled — the deployment does not have conditional writes enabled, so the argument is refused rather than ignored. Dropping it lets the call proceed unconditionally — subject to the usual errors, and with no lost-update protection at all.

An update_record sent without expected_revision stays unconditional, exactly as before.

Two permissions, both required

Write access is the intersection of two independent switches:

  1. The key's role must grant create / update on this Flux API (Dashboard → Environment → Flux Access). Keys without these grants don't even see the write tools in tools/list.
  2. The collection connection must allow writes. When connecting a collection to a Flux API you choose its allowed methods; create and update are off by default. A create_record against a collection that doesn't accept writes returns collection_not_writable — the hint lists which collections do.

This means the typical agent setup — read ten collections, write into one agent_memory collection — needs exactly one API: grant the agent's role create/update on the API, and enable writes only on the memory collection. The writable field in discover_resources tells the agent where it may write, so it doesn't have to probe.

When different roles must write to different collections, split the surface into two Flux APIs — a read API exposing everything and a write API exposing only the writable collections. The agent gets two MCP connectors with clean semantics ("your knowledge base" / "your memory"), and one role can carry different grants per API.

Tool results

Every successful tools/call returns:

{
  "content": [
    {"type": "text", "text": "<JSON-encoded structured content>"}
  ],
  "structuredContent": { /* the tool's structured payload */ },
  "isError": false
}

On a tool-level error (unknown_resource, missing_parent_id, access_denied, invalid_operator, etc.), isError is true and structuredContent is { error_code, message, hint }, plus details when the underlying response carried a structured body — content-validation errors[], or the detail of a failed precondition. This is not a JSON-RPC error — the result field is still populated and error is absent. JSON-RPC errors are reserved for protocol-level problems (missing session, unknown method, transport failures).


Page size

search_records and query_records return 5 records when limit is omitted, and accept up to 100. Both numbers are declared in the tool's JSON schema (default, maximum), so a model reading the catalogue knows them before it calls anything.

The default sits far below the REST page size, for a reason that is about pricing rather than payload: an MCP result is resent to the model on every subsequent turn, so records the agent did not need are paid for again and again. Five is enough to choose among hits and then fetch the one that matters in full with get_record.

That splits into two modes, and an agent needs to be told about the second:

  • Finding a document. Leave limit alone. Read the snippets, pick a hit, call get_record for the whole thing.
  • Enumerating or counting. Raise limit towards 100 rather than paging in fives. Pagination works, but each page is resent on every later turn, so many small pages cost far more than one large one — counting 205 records at 5 per call is 41 round trips instead of 3.

A limit above the maximum is clamped to it rather than refused, matching the REST endpoints. limit below 1, or non-integer, is a validation error (422).

Pagination itself is unchanged: pass cursor from the previous response's page.next_cursor, and page.limit reports the size that was actually applied.


Truncated text

The list tools may return shortened text fields. This is on by default, configured per collection-to-API connection via mcp_truncate_text (default 1000 characters, null to disable), and it exists for the same reason the page size is small: a long field an agent skims once is resent on every later turn.

What is and is not affected:

  • Only text-typed fields, at any nesting depth, and only in search_records / query_records. Identifiers, keys and _sys are never shortened — an agent builds its next call out of them.
  • get_record is never truncated. It is the route to the full document, which is what makes the find-then-fetch pair worth using.
  • Documents attached with populate are not truncated, and for that reason populate is refused on a list tool while truncation is active for that collection: the error (populate_unavailable_while_truncated) points you at get_record, which supports populate and returns everything in full.
  • Truncation does not engage at all on a connection without get_one — those list responses carry no data to shorten, and get_record would be denied there, so no result ever advises a tool the connection does not offer.

Every shortening is visible, in structuredContent rather than only in the text rendering:

{
  "items": [
    {
      "id": "hG9tL4nRmXwP",
      "_sys": {
        "key": "hG9tL4nRmXwP",
        "revision": "8f2b1c9d",
        "truncated": [
          {"field": "body", "locale": "en", "original_length": 41230}
        ]
      },
      "data": {"body": {"en": "Kubernetes is an open-source container orchestration sys"}}
    }
  ],
  "page": {"has_more": false, "next_cursor": null, "returned": 1, "limit": 5},
  "truncation": {
    "applied": true,
    "max_text_chars": 1000,
    "note": "Text fields longer than the limit were shortened; per-item details are in _sys.truncated. Call get_record with the item's id for the full document."
  }
}

The per-item _sys.truncated array carries the field path, the locale (null for non-localized fields) and the original character length. The top-level truncation block appears only when something was actually shortened, so its absence means the page is complete. A field already within the limit is returned untouched with no marker.


Enablement

The MCP endpoint is enabled by default for every Flux API prefix and can be turned off per API. When disabled, every method (GET, POST, DELETE) on /{api_prefix}/_mcp returns HTTP 404:

{ "detail": "MCP endpoint is disabled for this API" }

A deploy-wide kill-switch (MCP_ENABLED=false on the Flux server) takes precedence over the per-API flag; in that case the legacy message "MCP endpoint is disabled" is returned instead.

Data endpoints (search, list, get) are unaffected — only /_mcp is gated by this flag. Toggle the per-API flag via Management API — APIs (mcp_enabled field); the change propagates to Flux within seconds via the router cache invalidation channel.


Example: end-to-end session

A typical client opens a session, lists tools, calls one, then closes the session.

# 1. Initialize and capture the session id from the response header.
SID=$(curl -sS -i -X POST "https://7c9h4pwu.fxns.io/blog/_mcp" \
  -H "Authorization: Simple <public_key>:<private_key>" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
  | awk -F': ' 'tolower($1)=="mcp-session-id" {gsub(/\r/,"",$2); print $2}')

# 2. Reuse the session id on every subsequent request.
curl -sS -X POST "https://7c9h4pwu.fxns.io/blog/_mcp" \
  -H "Authorization: Simple <public_key>:<private_key>" \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

curl -sS -X POST "https://7c9h4pwu.fxns.io/blog/_mcp" \
  -H "Authorization: Simple <public_key>:<private_key>" \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: $SID" \
  -d '{
        "jsonrpc":"2.0","id":3,"method":"tools/call",
        "params":{
          "name":"search_records",
          "arguments":{
            "resource_id":"articles",
            "query":"machine learning",
            "search_type":"hybrid",
            "limit":3
          }
        }
      }'

# 3. Close the session.
curl -sS -X DELETE "https://7c9h4pwu.fxns.io/blog/_mcp" \
  -H "Authorization: Simple <public_key>:<private_key>" \
  -H "Mcp-Session-Id: $SID"

Response (tools/call)

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {"type": "text", "text": "{\"items\":[...],\"page\":{...}}"}
    ],
    "structuredContent": {
      "items": [
        {
          "id": "kDLT9jjrcAzh",
          "data": {"title": "...", "content": "...", "status": "published"},
          "_sys": {"key": "kDLT9jjrcAzh", "revision": "8f2b1c9d", "relevance": 0.503, "created_at": "..."}
        }
      ],
      "page": {"has_more": false, "next_cursor": null, "previous_cursor": null, "returned": 1, "limit": 3},
      "execution_info": {"applied_search_type": "hybrid", "search_mode": "hybrid"}
    },
    "isError": false
  }
}

Batched alternative — combine initialize and tools/call in one POST:

Batched request body

[
  {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}},
  {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"discover_resources","arguments":{}}}
]

Errors

JSON-RPC body errors (HTTP 200)

CodeMessageWhen returned
-32700Parse errorRequest body is not valid JSON.
-32600Invalid RequestPayload is neither object nor array, or a batched item is malformed.
-32600Unsupported Media TypeContent-Type is not application/json (HTTP 415).
-32601Method not found: <method>Unknown JSON-RPC method.
-32002Server not initializedMissing or expired Mcp-Session-Id.
-32003Billing limit reachedA tools/call is billing-gated: the plan allowance is exhausted (Free) or a spend cap is reached (paid). error.data mirrors the HTTP 402 body (plan_exhausted or spend_cap_reached, see Billing). The handshake (initialize, tools/list, ping) is never blocked.
-32000Too many initialize callsPer-IP rate limit exceeded (see Rate limiting).
-32000Session store unavailableBacking session store is unreachable; transient — retry.
-32000Runtime context missingRouter/environment context could not be loaded for this API.

Tool-level errors (inside result.structuredContent)

error_codeHint summary
unknown_resourceresource_id does not exist; call discover_resources.
missing_parent_idA nested resource_id template requires parents values not provided.
access_deniedDenied for one of two reasons, and the hint names the remedy for each: the collection is connected without the method you called — use query_records or search_records rather than re-opening the (already correct) resource_id; or the supplied API key/role has no access to the collection — use a different key or role. For write tools, the key's role lacks the create/update grant on this API.
invalid_operatorFilter op is not one of the supported values.
unknown_toolparams.name does not match any tool in tools/list.
precondition_failedexpected_revision no longer matches; details.detail.current_revision is the revision that is current now. Re-read, recompute, retry.
revision_number_conflictTwo writers collided on the next revision number; nothing was lost. Retry the same call.
invalid_expected_revisionexpected_revision is not a revision key.
conditional_write_disabledConditional writes are not enabled on this deployment, so the argument is refused rather than ignored.
collection_not_writableWrite tools only: the target collection's connection does not allow create/update through this API. The hint lists the writable collections.
content_validation_failedWrite tools only: data failed schema validation. Carries the standard content validation payload (errors[] with json_path).
external_id_conflictcreate_record only: a record with the supplied key already exists in the collection.
write_not_configuredWrite tools are unavailable on this endpoint, and are correspondingly absent from tools/list.
upstream_errorWrite tools only: the write pipeline failed or timed out. The outcome is unknown — check with get_record before retrying (writes are not retried automatically).
internal_errorUnexpected tool-level failure; retry or contact support.

HTTP-level

StatusBodyWhen returned
202(empty)JSON-RPC notification (no id) acknowledged.
204(empty)Successful DELETE of a session.
400{"jsonrpc":...,"error":{"code":-32700,...}}Malformed JSON body on POST.
404{"detail":"MCP endpoint is disabled"}Global MCP_ENABLED=false deploy-wide kill-switch.
404{"detail":"MCP endpoint is disabled for this API"}Per-API toggle is off (see Enablement).
405{"detail":"Method not allowed"}Method other than GET, POST, DELETE.
415{"jsonrpc":...,"error":{"code":-32600,"message":"Unsupported Media Type"}}Content-Type is not JSON.

Billing

MCP calls meter against the same billing axes as their REST equivalents:

  • A read tools/call counts as 1 Retrieval, regardless of how many records the tool returns (a batch of N results is still 1).
  • A write tools/call (create_record, update_record) counts as a weighted Writemax(1, ceil(embedded_tokens / 500)), same formula as REST writes. It does not additionally count as a Retrieval. The successful result reports the charge in write_units.
  • initialize, tools/list, and ping are free — the handshake never consumes allowance.
  • Failed calls are never billed.

When an allowance is exhausted (Free) or a spend cap is reached (paid), tools/call returns JSON-RPC error -32003 with the 402 payload in error.data. Free-plan axes stop independently: exhausted Retrievals block the read tools while the write tools keep working, and vice versa. The handshake keeps working so clients can still discover tools.


Was this page helpful?