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
POSTwithContent-Type: application/json. Both single-message and batched-array payloads are accepted. GET /{api_prefix}/_mcpreturns a health probe:{"status":"ok","protocol":"mcp-streamable-http","api":"..."}.DELETE /{api_prefix}/_mcpcloses the session referenced byMcp-Session-Idand always returns HTTP204(idempotent).- All JSON-RPC errors are returned inside the response body — HTTP status stays
200on 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:
- Client POSTs
initialize. The response sets theMcp-Session-Idheader to a server-generated UUID. - Every subsequent POST and DELETE must echo that UUID in the same header.
- Sessions expire after 1 hour of inactivity; every valid touch refreshes the TTL.
- A
DELETE /{api_prefix}/_mcpwith 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
resultobject.
- 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/callre-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 carriesresource_id,title,path_template,required_parents,capabilities(subset ofget_one,get_many,search),writable(subset ofcreate,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 Introspection —json_schema,searchable_fields,non_searchable_fields,actions,path, etc. Thejson_schemais self-contained: nested-Component fields appear as{"$ref": "#/$defs/<component>"}with every referenced Component inlined in an embedded$defsblock, 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? }.limitdefaults 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. Supportedopvalues: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_typeis one oftext,semantic,hybrid,vector_boosted.limitdefaults 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? }.datamust match the collection schema (inspect it withdescribe_resource); the optionalkeyis an external identifier for deduplication — creating a second record with the samekeyreturns anexternal_id_conflicterror 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_revisionmakes 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. Themessagenames the revision that is current now, anddetailsrelays the underlying response, so the value is also atdetails.detail.current_revision. Re-read withget_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:
- The key's role must grant
create/updateon this Flux API (Dashboard → Environment → Flux Access). Keys without these grants don't even see the write tools intools/list. - The collection connection must allow writes. When connecting a collection to a Flux API you choose its allowed methods;
createandupdateare off by default. Acreate_recordagainst a collection that doesn't accept writes returnscollection_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
limitalone. Read the snippets, pick a hit, callget_recordfor the whole thing. - Enumerating or counting. Raise
limittowards 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 insearch_records/query_records. Identifiers, keys and_sysare never shortened — an agent builds its next call out of them. get_recordis never truncated. It is the route to the full document, which is what makes the find-then-fetch pair worth using.- Documents attached with
populateare not truncated, and for that reasonpopulateis refused on a list tool while truncation is active for that collection: the error (populate_unavailable_while_truncated) points you atget_record, which supportspopulateand returns everything in full. - Truncation does not engage at all on a connection without
get_one— those list responses carry nodatato shorten, andget_recordwould 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)
| Code | Message | When returned |
|---|---|---|
-32700 | Parse error | Request body is not valid JSON. |
-32600 | Invalid Request | Payload is neither object nor array, or a batched item is malformed. |
-32600 | Unsupported Media Type | Content-Type is not application/json (HTTP 415). |
-32601 | Method not found: <method> | Unknown JSON-RPC method. |
-32002 | Server not initialized | Missing or expired Mcp-Session-Id. |
-32003 | Billing limit reached | A 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. |
-32000 | Too many initialize calls | Per-IP rate limit exceeded (see Rate limiting). |
-32000 | Session store unavailable | Backing session store is unreachable; transient — retry. |
-32000 | Runtime context missing | Router/environment context could not be loaded for this API. |
Tool-level errors (inside result.structuredContent)
error_code | Hint summary |
|---|---|
unknown_resource | resource_id does not exist; call discover_resources. |
missing_parent_id | A nested resource_id template requires parents values not provided. |
access_denied | Denied 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_operator | Filter op is not one of the supported values. |
unknown_tool | params.name does not match any tool in tools/list. |
precondition_failed | expected_revision no longer matches; details.detail.current_revision is the revision that is current now. Re-read, recompute, retry. |
revision_number_conflict | Two writers collided on the next revision number; nothing was lost. Retry the same call. |
invalid_expected_revision | expected_revision is not a revision key. |
conditional_write_disabled | Conditional writes are not enabled on this deployment, so the argument is refused rather than ignored. |
collection_not_writable | Write tools only: the target collection's connection does not allow create/update through this API. The hint lists the writable collections. |
content_validation_failed | Write tools only: data failed schema validation. Carries the standard content validation payload (errors[] with json_path). |
external_id_conflict | create_record only: a record with the supplied key already exists in the collection. |
write_not_configured | Write tools are unavailable on this endpoint, and are correspondingly absent from tools/list. |
upstream_error | Write 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_error | Unexpected tool-level failure; retry or contact support. |
HTTP-level
| Status | Body | When 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/callcounts 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 Write —max(1, ceil(embedded_tokens / 500)), same formula as REST writes. It does not additionally count as a Retrieval. The successful result reports the charge inwrite_units. initialize,tools/list, andpingare 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.
Related Endpoints
- Router Introspection — flat catalog of REST routes; complementary to
tools/listfor clients that prefer plain HTTP. - Schema Introspection — JSON Schema for a specific collection; equivalent to the
describe_resourcetool. - Search — REST equivalent of the
search_recordstool. - List Resources — REST equivalent of
query_records. - Get Resource — REST equivalent of
get_record. - Create Resource / Update Resource — REST equivalents of
create_record/update_record.