InfiniteOceanv2

MCP for agents

Connect a coding agent to InfiniteOcean v2 over JSON-RPC at POST /v2/mcp/rpc, with three tools — ocean_get, ocean_list and ocean_drop — authenticated by the X-Ocean-Key header.

InfiniteOcean v2 exposes an MCP (Model Context Protocol) adapter so a coding agent can read and write your data without you writing any glue code. It is a JSON-RPC 2.0 endpoint at POST /v2/mcp/rpc offering three tools: ocean_get, ocean_list and ocean_drop.

Every example on this page was run against https://api2.infiniteocean.io and the responses are the real ones, copied verbatim.

Authentication

Authentication is the X-Ocean-Key header. Nothing else works.

-H "X-Ocean-Key: <your-key>"

Bearer tokens are not accepted. The v2 server does not read the Authorization header on this endpoint at all — a request carrying only a Bearer token is treated as a request with no key:

curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/mcp/rpc \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OCEAN_KEY" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
{"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"}
401

There is no OAuth flow and no token exchange on this endpoint. If your MCP client only knows how to send Authorization: Bearer, it cannot authenticate here; it must be configured to send a custom header.

A missing key is a 401, but a wrong key is not

This catches people out, so it is worth stating plainly. Only tools/call checks whether your key is real. initialize, ping and tools/list return 200 for any non-empty key value, including nonsense:

curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/mcp/rpc \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: not-a-real-key" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
200

A successful handshake does not prove your key is valid. To check a key, make a real tools/call. With a bad key that call comes back as a tool error rather than an HTTP error, and the wording differs between reads and writes:

// ocean_get with a bad key
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"forbidden: no read on route"}],"isError":true}}

// ocean_drop with a bad key
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"forbidden: unknown key"}],"isError":true}}

A read cannot tell you whether your key is unknown or merely unauthorised for that route — both produce forbidden: no read on route. A write is the reliable key check.

Transport

This is plain JSON-RPC over HTTP POST. One JSON-RPC message per request, one response per request.

The handshake

1. initialize

curl -s -X POST https://api2.infiniteocean.io/v2/mcp/rpc \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2024-11-05","capabilities":{},
                 "clientInfo":{"name":"curl","version":"1.0"}}}'
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"InfiniteOcean/v2","version":"2.0"},"instructions":"Use ocean_get, ocean_list, ocean_drop with an X-Ocean-Key. Routes are folders; keys are entity ids. ocean_drop claims a new unclaimed route the same way POST /v2/drop does (first write owns it). Payloads are JSON objects or strings."}}

The server announces protocol version 2024-11-05 and the tools capability only. There are no resources and no prompts. The params you send are not validated — initialize with no params field at all returns a byte-identical result.

2. notifications/initialized

curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/mcp/rpc \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
{}
202

3. tools/list

curl -s -X POST https://api2.infiniteocean.io/v2/mcp/rpc \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
{"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"ocean_get","description":"Read one entity by route and key. Returns the JSON payload or not_found.","inputSchema":{"type":"object","properties":{"route":{"type":"string","description":"Route (folder)"},"key":{"type":"string","description":"Entity key"}},"required":["route","key"]}},
{"name":"ocean_list","description":"List entities on a route (paged). Optional cursor and limit (default 50, max 200).","inputSchema":{"type":"object","properties":{"route":{"type":"string"},"cursor":{"type":"string","description":"Key to start after"},"limit":{"type":"integer","description":"Page size 1-200"}},"required":["route"]}},
{"name":"ocean_drop","description":"Write one entity (LWW). On an unclaimed route, first write claims ownership (same as POST /v2/drop). payload must be a JSON value (object or string).","inputSchema":{"type":"object","properties":{"route":{"type":"string"},"key":{"type":"string"},"payload":{"description":"JSON value to store"}},"required":["route","key","payload"]}}
]}}

The tool list is static. Sent with a valid key and with a deliberately invalid key, the two responses were byte-for-byte identical, so the list tells you nothing about what your key can reach.

ping

{"jsonrpc":"2.0","id":3,"method":"ping"} returns {"jsonrpc":"2.0","id":3,"result":{}}.

Reading a tool result

Tool results follow the MCP content envelope, and the payload arrives as a JSON string inside content[0].text. You have to decode twice — once for the JSON-RPC response, once for the text field:

{"jsonrpc":"2.0","id":31,"result":{"content":[{"type":"text","text":"{\"found\":true,\"route\":\"_docs_probe/mcp\",\"key\":\"task-42\",\"payload\":{\"title\":\"Ship the docs\",\"done\":false}}"}]}}

A failed tool call sets isError: true, and in that case content[0].text is a plain sentence, not JSON. Do not attempt to parse it:

{"jsonrpc":"2.0","id":19,"result":{"content":[{"type":"text","text":"forbidden: no read on route"}],"isError":true}}

So the decoding rule is: check result.isError first; if it is absent, JSON.parse(result.content[0].text); if it is true, treat the text as a human-readable message.

Tools

ocean_get

Reads the current value of one entity.

ArgumentTypeRequiredNotes
routestringyesMust be a non-empty string. A number is treated as absent.
keystringyesMust be a non-empty string.
curl -s -X POST https://api2.infiniteocean.io/v2/mcp/rpc \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"jsonrpc":"2.0","id":31,"method":"tools/call",
       "params":{"name":"ocean_get",
                 "arguments":{"route":"_docs_probe/mcp","key":"task-42"}}}'
{"jsonrpc":"2.0","id":31,"result":{"content":[{"type":"text","text":"{\"found\":true,\"route\":\"_docs_probe/mcp\",\"key\":\"task-42\",\"payload\":{\"title\":\"Ship the docs\",\"done\":false}}"}]}}

If the key does not exist, the call succeeds and the decoded text is {"found":false} — there is no isError and no 404. Check the found field:

{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"{\"found\":false}"}]}}

A missing key and a key whose stored payload is null are distinguishable. A stored null returns {"found":true,"route":"_docs_probe/mcp","key":"null-payload","payload":null}; only a missing key returns {"found":false}.

ocean_list

Lists entities on a route, one page at a time, ordered by key.

ArgumentTypeRequiredNotes
routestringyesMust be a non-empty string.
cursorstringnoStart after this key. Pass back the next_cursor you were given.
limitintegernoDefaults to 50, clamped to 200. Must be a JSON integer — see the warning below.
curl -s -X POST https://api2.infiniteocean.io/v2/mcp/rpc \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"jsonrpc":"2.0","id":8,"method":"tools/call",
       "params":{"name":"ocean_list",
                 "arguments":{"route":"_docs_probe/mcp","limit":2}}}'

Decoded content[0].text:

{"items":[
  {"key":"note-1","ts":1787593712320,"payload":{"title":"First note","status":"published"}},
  {"key":"note-2","ts":1787593100219,"payload":{"title":"Note 2","status":"draft"}}
],"next_cursor":"note-2","complete":false,"index_present":true}

Continue by passing that cursor back:

-d '{"jsonrpc":"2.0","id":9,"method":"tools/call",
     "params":{"name":"ocean_list",
               "arguments":{"route":"_docs_probe/mcp","cursor":"note-2","limit":2}}}'

A page that reaches the end of the route returns next_cursor: null and, if the route has a durable index, complete: true:

{"items":[
  {"key":"k200","ts":1787593964483,"payload":{"n":"200"}},
  {"key":"k201","ts":1787593964505,"payload":{"n":"201"}},
  {"key":"k202","ts":1787593964550,"payload":{"n":"202"}}
],"next_cursor":null,"complete":true,"index_present":true}

(that last page is trimmed to three of its ten items for length; every other response on this page is verbatim.)

A cursor past the last key returns {"items":[],"next_cursor":null,"complete":true,"index_present":true} rather than an error.

The response fields

FieldMeaning
items[].keyEntity key.
items[].tsWrite time, Unix milliseconds.
items[].payloadThe stored JSON value.
next_cursorPass as cursor for the next page. null means this page reached the end of what the server can see.
index_presentWhether a durable route index was found for this route.
completeWhether the server certifies that you have seen the whole route.

complete is the flag that matters, not next_cursor

complete: true was observed only on a page that (a) reached the end of the route and (b) had index_present: true. Every mid-pagination page reads complete: false, which is normal and not an error — it only means "there is another page".

The case to guard against is next_cursor: null together with complete: false. On a route written seconds earlier, before its durable index existed, listing returned {"items":[...],"next_cursor":null,"complete":false,"index_present":false}. That combination means "this is the end of what I can see, and I will not promise it is the end of the route".

Practical rule: stop when next_cursor is null, but only treat the result as the full route when complete is true. Do not retry on complete: false in a tight loop — the items you were given are real either way.

Warning: a non-integer limit is silently ignored

limit must be a JSON number. If you send it as a string, it is not rejected and not coerced — it is dropped, and you silently get the default page size of 50. On a 210-entity route, "limit":"5" returned 50 items. Send 5, never "5".

Values above 200 are clamped, not rejected. On the same 210-entity route, "limit":500 and "limit":201 both returned exactly 200 items with next_cursor set.

limit below 1 is a hard error: {"code":-32602,"message":"limit must be >= 1"}.

ocean_drop

Writes one entity. Last write wins — writing an existing key replaces its value.

ArgumentTypeRequiredNotes
routestringyesMust be a non-empty string.
keystringyesMust be a non-empty string.
payloadany JSON valueyesObject, bare string and null were all accepted and read back unchanged.
curl -s -X POST https://api2.infiniteocean.io/v2/mcp/rpc \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"jsonrpc":"2.0","id":30,"method":"tools/call",
       "params":{"name":"ocean_drop",
                 "arguments":{"route":"_docs_probe/mcp","key":"task-42",
                              "payload":{"title":"Ship the docs","done":false}}}}'
{"jsonrpc":"2.0","id":30,"result":{"content":[{"type":"text","text":"{\"ok\":true,\"seq\":1787577342969431,\"route\":\"_docs_probe/mcp\",\"key\":\"task-42\",\"rule_created\":false}"}]}}

rule_created tells you whether this write claimed the route — see below.

Do not treat seq as a unique write id. Two consecutive writes of different keys to the same route both returned seq: 1787577342969462. It is a sequence number from the write path, not an identifier you can key off.

Writing the same key again replaces the value. This was verified by writing {"title":"First note","status":"draft"}, then writing {"title":"First note","status":"published"} to the same route and key, then reading it back:

{"found":true,"route":"_docs_probe/mcp","key":"note-1","payload":{"title":"First note","status":"published"}}

Route ownership

Routes are claimed by first write, exactly as with POST /v2/drop. The first ocean_drop to an unclaimed route creates the access rule and returns rule_created: true:

{"ok":true,"seq":1787577342969462,"route":"_docs_probe/mcp-claim-20260824","key":"first","rule_created":true}

Every later write to that route returns rule_created: false. Two consequences matter for an agent:

Errors

HTTP-level

StatusBodyCause
401{"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"}No X-Ocean-Key header, or it is empty. A Bearer token alone lands here.
400{"ok":false,"error":"JSON-RPC body required — nothing was applied","code":"INVALID_JSON"}Empty request body.
400{"ok":false,"error":"unknown query parameter \"foo\" — nothing was applied","code":"UNKNOWN_PARAM"}Any query string. This endpoint takes none.
404{"ok":false,"error":"no /v2 endpoint matches this method and path","code":"NOT_FOUND"}Wrong method — GET and PUT on /v2/mcp/rpc both land here.
202{}A notifications/* message. Expected, not an error.

JSON-RPC level (all returned with HTTP 200)

CodeMessageCause
-32700parse errorBody is not valid JSON. id comes back null.
-32600invalid requestBody is valid JSON but not an object — a batch array lands here. id comes back null.
-32600missing methodNo method field.
-32601Method not foundUnsupported method, e.g. resources/list.
-32602params requiredtools/call with no params.
-32602arguments requiredparams.arguments missing.
-32602arguments must be objectparams.arguments is present but not an object.
-32602unknown toolparams.name is not one of the three tools.
-32602route required / key required / payload requiredA required tool argument is missing. route and key must also be strings — "route":123 produces route required.
-32602route and key must be non-emptyroute or key is "".
-32602limit must be >= 1limit is an integer below 1.

Tool level (HTTP 200, result.isError: true)

These are returned as results rather than JSON-RPC errors, because the call was well-formed but could not be completed.

TextCause
forbidden: no read on routeYour key has no read access to the route, or the route is unclaimed. Also what an invalid key produces on a read.
forbidden: unknown keyThe key is not recognised, on a write.
forbidden: reserved system routeRoute begins with __.
forbidden: no write on routeRoute is owned by a different key. Listed for completeness — see the caveat at the foot of this page.
transient: retryTemporary read failure. Retry.
backpressure — retryWrite shed under load. Retry with backoff.
WAL rehydrating — retryNode is recovering its write log. Retry.

A separate endpoint: the MCP server registry

GET /v2/mcp and POST /v2/mcp are not the JSON-RPC adapter. They are a small registry where you record MCP servers of your own against your account. Nothing on this endpoint calls those servers or proxies to them — it stores name/URL pairs and gives them ids.

GET /v2/mcp

Lists every server ever registered under your key, active or not. Requires X-Ocean-Key. There is no paging: it returns the whole list in one response, and a query parameter is a hard error (GET /v2/mcp?limit=5 returns 400 UNKNOWN_PARAM).

curl -s https://api2.infiniteocean.io/v2/mcp -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"servers":[{"id":"9a92e0aed98f-b6bd8cdca219","name":"docs-probe","url":"https://tools.example/mcp","active":true}]}

POST /v2/mcp

Takes an op field, either register or remove.

curl -s -X POST https://api2.infiniteocean.io/v2/mcp \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"op":"register","name":"docs-probe","url":"https://tools.example/mcp"}'
{"ok":true,"id":"9a92e0aed98f-b6bd8cdca219"}
curl -s -X POST https://api2.infiniteocean.io/v2/mcp \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"op":"remove","id":"9a92e0aed98f-b6bd8cdca219"}'
{"ok":true,"removed":true}

remove deactivates; it does not delete

After a successful remove, the entry is still in GET /v2/mcp, with "active": false:

{"id":"9a92e0aed98f-b6bd8cdca219","name":"docs-probe","url":"https://tools.example/mcp","active":false}

So filter on active when you read the list. The count returned by GET /v2/mcp grows with every registration you have ever made and never shrinks.

Removing an id that has already been removed returns {"ok":true,"removed":true} again — the response does not tell you whether the server was active. Removing an id that never existed does fail:

{"ok":false,"error":"mcp server not found","code":"NOT_FOUND"}
404

Registry errors

StatusBodyCause
400{"ok":false,"error":"body must include op — nothing was applied","code":"MISSING_FIELD"}No op field.
400{"ok":false,"error":"register requires name — nothing was applied","code":"MISSING_FIELD"}register without name.
400{"ok":false,"error":"register requires url — nothing was applied","code":"MISSING_FIELD"}register without url.
400{"ok":false,"error":"op must be register|remove — nothing was applied","code":"INVALID_INPUT"}op is anything else.
401{"ok":false,"error":"unknown key — not authenticated","code":"UNKNOWN_KEY"}Key not recognised. Applies to both GET and POST.
404{"ok":false,"error":"mcp server not found","code":"NOT_FOUND"}remove with an id that does not exist.
400{"ok":false,"error":"unknown query parameter \"limit\" — nothing was applied","code":"UNKNOWN_PARAM"}Any query string on GET /v2/mcp.

What this adapter does not do

Base URL https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.