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 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.
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.
This is plain JSON-RPC over HTTP POST. One JSON-RPC message per request, one response per request.
GET /v2/mcp/rpc returns 404 NOT_FOUND, as does any method other than POST. Drive the endpoint with direct POSTs, or put a small stdio-to-HTTP shim in front of it.-32600 invalid request.POST /v2/mcp/rpc?foo=1 returns 400 with {"ok":false,"error":"unknown query parameter \"foo\" — nothing was applied","code":"UNKNOWN_PARAM"}. Put everything in the body.200 with a JSON-RPC error object or an isError result. Only a missing key (401), an empty body (400), an unknown query parameter (400) and a wrong method (404) are HTTP-level failures.notifications/ is accepted with 202 and the body {}. The suffix is not validated — notifications/made-this-up is accepted the same way.content-type: application/json.id may be a number or a string; it is echoed 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":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.
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
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.
{"jsonrpc":"2.0","id":3,"method":"ping"} returns {"jsonrpc":"2.0","id":3,"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.
Reads the current value of one entity.
| Argument | Type | Required | Notes |
|---|---|---|---|
route | string | yes | Must be a non-empty string. A number is treated as absent. |
key | string | yes | Must 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}.
Lists entities on a route, one page at a time, ordered by key.
| Argument | Type | Required | Notes |
|---|---|---|---|
route | string | yes | Must be a non-empty string. |
cursor | string | no | Start after this key. Pass back the next_cursor you were given. |
limit | integer | no | Defaults 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.
| Field | Meaning |
|---|---|
items[].key | Entity key. |
items[].ts | Write time, Unix milliseconds. |
items[].payload | The stored JSON value. |
next_cursor | Pass as cursor for the next page. null means this page reached the end of what the server can see. |
index_present | Whether a durable route index was found for this route. |
complete | Whether the server certifies that you have seen the whole route. |
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.
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"}.
Writes one entity. Last write wins — writing an existing key replaces its value.
| Argument | Type | Required | Notes |
|---|---|---|---|
route | string | yes | Must be a non-empty string. |
key | string | yes | Must be a non-empty string. |
payload | any JSON value | yes | Object, 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"}}
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:
ocean_list on a route with no writes returns isError: true and "forbidden: no read on route". An agent probing for a route it has not written to will see a permission error, not an empty list. That is not a bug and not a key problem.__ are reserved and can never be claimed. ocean_drop to __system returns isError: true with "forbidden: reserved system route".| Status | Body | Cause |
|---|---|---|
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. |
| Code | Message | Cause |
|---|---|---|
-32700 | parse error | Body is not valid JSON. id comes back null. |
-32600 | invalid request | Body is valid JSON but not an object — a batch array lands here. id comes back null. |
-32600 | missing method | No method field. |
-32601 | Method not found | Unsupported method, e.g. resources/list. |
-32602 | params required | tools/call with no params. |
-32602 | arguments required | params.arguments missing. |
-32602 | arguments must be object | params.arguments is present but not an object. |
-32602 | unknown tool | params.name is not one of the three tools. |
-32602 | route required / key required / payload required | A required tool argument is missing. route and key must also be strings — "route":123 produces route required. |
-32602 | route and key must be non-empty | route or key is "". |
-32602 | limit must be >= 1 | limit is an integer below 1. |
result.isError: true)These are returned as results rather than JSON-RPC errors, because the call was well-formed but could not be completed.
| Text | Cause |
|---|---|
forbidden: no read on route | Your key has no read access to the route, or the route is unclaimed. Also what an invalid key produces on a read. |
forbidden: unknown key | The key is not recognised, on a write. |
forbidden: reserved system route | Route begins with __. |
forbidden: no write on route | Route is owned by a different key. Listed for completeness — see the caveat at the foot of this page. |
transient: retry | Temporary read failure. Retry. |
backpressure — retry | Write shed under load. Retry with backoff. |
WAL rehydrating — retry | Node is recovering its write log. Retry. |
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.
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}]}
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}
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
| Status | Body | Cause |
|---|---|---|
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. |
/v2/mcp/rpc.initialize advertises the tools capability only.ocean_list pages by key; it does not filter by field or sort.https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.