Docs
View as markdown

MCP server

relate's own MCP server over stdio or streamable HTTP; its three tools with input schemas and results, the read-only policy, refusals, auth, and limits.

mcpagentscli

relate serve --mcp serves one relate store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary to agent hosts over MCP The Model Context Protocol, which agent hosts use to call tools; relate serve --mcp exposes a store as read_cypher, get_schema and write_cypher. Read more in the glossary (the Model Context Protocol), with three tools: get_schema, read_cypher and write_cypher. It runs on the host's stdin and stdout (--mcp stdio) or as a streamable HTTP listener (--mcp <port>), and a store opened read-only An open mode (--read-only) that never writes, checkpoints or migrates the store; writes fail, and a directory store can be read beside its writer. Read more in the glossary lists only the first two. This server reads and writes a store of yours.

Synopsis

relate serve --database PATH --mcp stdio [--read-only[=FRESHNESS]] [--safe]
relate serve --database PATH --mcp PORT [--host HOST] [--auth USER:PASSWORD] [--read-only[=FRESHNESS]] [--safe]
ItemValue
ProtocolJSON-RPC 2.0, one message per line on stdio, one message per POST body on HTTP
serverInfoname relate-mcp, version the relate version (0.1.2)
protocolVersionThe client's protocolVersion from initialize, echoed; 2024-11-05 when the client sends none
Capabilitiestools only: no resources, prompts, or sampling
Toolsget_schema, read_cypher, write_cypher; write_cypher is not listed when the store is opened --read-only
SessionsNone. Each request stands alone; no Mcp-Session-Id is issued
AuthenticationNone on stdio; HTTP Basic on the HTTP transport, from one credential shared with the other relate serve surfaces
WritesAllowed unless the store is opened --read-only; read_cypher refuses writes and schema changes either way
Flags--mcp and the other relate serve flags are on relate serve and in CLI flags

Transports

--mcp takes the keyword stdio or a port number, once per run. A second --mcp exits 2 with Error: serve --mcp may be given only once.

--mcp stdio--mcp PORT
FramingNewline-delimited JSON-RPC on stdin and stdoutOne complete JSON-RPC message per HTTP/1.1 POST; the request path is not read
Started byThe MCP host, as a child processAn operator, as a long-running process
Runs untilThe host closes stdin; exit code 0SIGINT or SIGTERM
AuthenticationNone. --auth with --mcp stdio and no --http or --bolt listener beside it exits 2 with an error that begins Error: serve --mcp stdio skips auth; with one beside it, --auth applies to that listener onlyHTTP Basic, from --auth, RELATE_AUTH or a generated token (Authentication)
BindsNothing127.0.0.1:PORT; --host changes the address, with no TLS on any address
stdoutJSON-RPC responses onlyNot used
stderrStartup and shutdown linesStartup and shutdown lines
Browser requestsNot applicableA present Origin must be loopback (localhost, 127.0.0.1 or [::1], any port, http or https); no CORS headers
Other surfaces in the same process--http and --bolt listeners can run beside it and stop when stdin closes--http and --bolt can run beside it

Methods

MethodResult
initializeprotocolVersion, capabilities ({"tools": {}}) and serverInfo
notifications/initialized, any notifications/*No response on stdio; HTTP 202 with an empty body on HTTP
ping{}
tools/listtools: each tool's name, description, inputSchema and annotations
tools/callcontent (one item, type text, the result serialized as JSON in text) and isError
Any other methodJSON-RPC error -32601, message Method not found: <method>

The messages a host sends to open a session, against a writable in-memory store so that every tool is listed:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"shell","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  '{"jsonrpc":"2.0","id":3,"method":"ping"}' \
  | relate serve --mcp stdio --database :memory: 2>/dev/null
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"relate-mcp","version":"0.1.2"}}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"annotations":{"destructiveHint":true,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Write Cypher"},"description":"Execute one writing Cypher statement (CREATE, MERGE, SET, DELETE, …). `parameters` is passed as the statement's parameters; an object with a `rows` array feeds the statement's `$rows` parameter. Returns the mutation counters.","inputSchema":{"properties":{"parameters":{"default":{},"description":"The statement's parameters; a `rows` array feeds `$rows`.","type":"object"},"statement":{"description":"The Cypher statement.","type":"string"}},"required":["statement"],"type":"object"},"name":"write_cypher"},{"annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"Read Cypher"},"description":"Execute one read-only Cypher statement. Returns the result's rows. Write and schema (DDL) statements are rejected.","inputSchema":{"properties":{"parameters":{"default":{},"description":"The statement's parameters; a `rows` array feeds `$rows`.","type":"object"},"statement":{"description":"The Cypher statement.","type":"string"}},"required":["statement"],"type":"object"},"name":"read_cypher"},{"annotations":{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"Get Schema"},"description":"Get the schema of the graph store: node labels with their property keys and inferred types, and the relationship types between them. `sample_size` bounds how many nodes and relationships are examined per label (larger is slower but more accurate; -1 examines all).","inputSchema":{"properties":{"sample_size":{"description":"The sample size used to infer the graph schema. Larger samples are slower, but more accurate. Smaller samples are faster, but might miss information. -1 samples the whole graph.","type":"integer"}},"type":"object"},"name":"get_schema"}]}}
{"jsonrpc":"2.0","id":3,"result":{}}

The notification got no response line, and tools/list names write_cypher, read_cypher and get_schema, in that order.

Tools

ToolListedAnnotationsInputResult in content[0].text
get_schemaAlwaysreadOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: falsesample_size (integer, optional)A schema map keyed by label
read_cypherAlwaysreadOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: falsestatement (string, required); parameters (object, optional, default {})The result envelope, with rows
write_cypherOnly when the store is writablereadOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: falsestatement (string, required); parameters (object, optional, default {})The result envelope: empty rows, plus the mutation counters

Each call runs one statement. The examples in this section run against team.rl, the shared example graph of three people and two projects. This step writes it, with the CLI:

relate --database ./team.rl "CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace)"

The same graph is in this page's in-browser database, and the tryout answers the three tools on it with the calls this section records. Each reply is built from the engine's result the way the server builds it; apart from the timings, it is the reply the recorded runs below print:

Cypher
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace)
Recorded result
(no rows) · nodes created: 5, relationships created: 5, properties set: 13, labels added: 5

These calls ran when the page was recorded, on a database that holds what this page's cells write, through the same mapping the live tryout uses. Timings show as <ms>; live, each reply carries the timings of its own run. The server lists write_cypher, read_cypher, get_schema.

get_schema: the request a host sends, then the reply.

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema","arguments":{}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"{\"Person\":{\"count\":3,\"properties\":{\"name\":{\"type\":\"String\"},\"role\":{\"type\":\"String\"}},\"relationships\":{\"KNOWS\":{\"direction\":\"out\",\"labels\":[\"Person\"]},\"MAINTAINS\":{\"direction\":\"out\",\"labels\":[\"Project\"]},\"WORKS_ON\":{\"direction\":\"out\",\"labels\":[\"Project\"],\"properties\":{\"since\":{\"type\":\"Integer\"}}}},\"type\":\"node\"},\"Project\":{\"count\":2,\"properties\":{\"language\":{\"type\":\"String\"},\"name\":{\"type\":\"String\"}},\"type\":\"node\"}}","type":"text"}],"isError":false}}
LabelNodesPropertiesRelationships out
Person3name (String), role (String)WORKS_ON to Project; KNOWS to Person; MAINTAINS to Project
Project2language (String), name (String)-

read_cypher: the request a host sends, then the reply.

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY since","parameters":{"project":"relate"}}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\"columnTypes\":[\"STRING\",\"INTEGER\"],\"columns\":[\"name\",\"since\"],\"elapsed_ms\":<ms>,\"error\":null,\"execute_ms\":<ms>,\"lex_parse_ms\":<ms>,\"lower_ms\":<ms>,\"notifications\":[{\"category\":\"PERFORMANCE\",\"code\":\"Relate.Notification.Statement.MissingIndex\",\"description\":\"the equality predicate on name filters :Project without a range index on (:Project {name})\",\"gql_status\":\"03C91\",\"operator_id\":1,\"remedy\":\"CREATE INDEX relate_Project__name FOR (n:Project) ON (n.name)\",\"severity\":\"INFORMATION\",\"title\":\"Missing index\"}],\"parse_ms\":<ms>,\"plan\":{\"profiled\":false,\"root\":{\"actual\":null,\"children\":[{\"actual\":null,\"children\":[],\"details\":\"(:Project)<-[w:WORKS_ON]-(p:Person)\",\"estimated_rows\":null,\"identifiers\":[],\"operator\":\"Expand\",\"warnings\":[{\"category\":\"PERFORMANCE\",\"code\":\"Relate.Notification.Statement.MissingIndex\",\"description\":\"the equality predicate on name filters :Project without a range index on (:Project {name})\",\"gql_status\":\"03C91\",\"remedy\":\"CREATE INDEX relate_Project__name FOR (n:Project) ON (n.name)\",\"severity\":\"INFORMATION\",\"title\":\"Missing index\"}]}],\"details\":\"2 item(s), ordered\",\"estimated_rows\":null,\"identifiers\":[],\"operator\":\"ProduceResults\",\"warnings\":[]},\"schema\":\"relate.plan/v1\",\"statement\":\"MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY since\"},\"plan_cache\":\"miss\",\"plan_ms\":<ms>,\"result_generation_ms\":<ms>,\"rewrite_ms\":<ms>,\"rows\":[{\"name\":\"Grace\",\"since\":2023},{\"name\":\"Ada\",\"since\":2024}],\"stats\":{\"constraints_added\":0,\"constraints_removed\":0,\"indexes_added\":0,\"indexes_removed\":0,\"labels_added\":0,\"labels_removed\":0,\"nodes_created\":0,\"nodes_deleted\":0,\"properties_set\":0,\"relationships_created\":0,\"relationships_deleted\":0}}","type":"text"}],"isError":false}}
namesince
Grace2023
Ada2024

write_cypher: the request a host sends, then the reply.

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"CREATE (:Person {name: $name, role: $role})","parameters":{"name":"Ada","role":"engineer"}}}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"{\"columnTypes\":[],\"columns\":[],\"elapsed_ms\":<ms>,\"error\":null,\"execute_ms\":<ms>,\"lex_parse_ms\":<ms>,\"lower_ms\":<ms>,\"notifications\":[],\"parse_ms\":<ms>,\"plan\":{\"profiled\":false,\"root\":{\"actual\":null,\"children\":[],\"details\":\"(:Person)\",\"estimated_rows\":null,\"identifiers\":[],\"operator\":\"Create\",\"warnings\":[]},\"schema\":\"relate.plan/v1\",\"statement\":\"CREATE (:Person {name: $name, role: $role})\"},\"plan_cache\":\"miss\",\"plan_ms\":<ms>,\"result_generation_ms\":<ms>,\"rewrite_ms\":<ms>,\"rows\":[],\"stats\":{\"constraints_added\":0,\"constraints_removed\":0,\"indexes_added\":0,\"indexes_removed\":0,\"labels_added\":1,\"labels_removed\":0,\"nodes_created\":1,\"nodes_deleted\":0,\"properties_set\":2,\"relationships_created\":0,\"relationships_deleted\":0}}","type":"text"}],"isError":false}}

Counters: nodes_created 1, properties_set 2, labels_added 1; rows is empty, as write_cypher returns counters.

write_cypher on a server started --read-only: it lists only read_cypher and get_schema, and answers a write_cypher call with a tool error before the engine sees the statement. This run is recorded from relate 0.1.2 (errors.json mcp-unknown-tool, replayed against the binary by docs:cli-check); the page quotes it as the binary printed it:

printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_cypher","arguments":{"query":"CREATE (:B)"}}}' | relate serve --mcp stdio --database ./graph.rl --read-only
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"relate-mcp","version":"0.1.2"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"unknown tool 'write_cypher'","type":"text"}],"isError":true}}

Transport answers (HTTP 401, 403 and 415, the body cap, the header deadline) are not part of the tryout; the MCP server reference lists them.

Live: each call is a button that sends its tools/call request to the page's database in your browser and shows the request and the reply, built from the engine's result the way relate serve --mcp builds it, with the statement and parameters editable; "Serve read-only" drops write_cypher from the tool list, as --read-only does, and shows the engine's own refusal of the write on a read-only database beside the server's recorded answer.

get_schema

InputTypeDefaultMeaning
sample_sizeinteger1000How many nodes and relationships per label are examined to infer property types and relationship types; -1 examines all. Any other negative value is refused

The result is a JSON object keyed by node label. Each entry holds type ("node"), count (the exact number of nodes with the label), properties (each key with its inferred type, such as String or Integer) and, when the sample found any, relationships: each outgoing relationship type with its direction, the labels at the other end, and its own properties. Property types come from the sample, and a key seen with mixed types keeps the first type seen. Counts never depend on the sample; the relationship types do:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema","arguments":{}}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_schema","arguments":{"sample_size":1}}}' \
  | relate serve --mcp stdio --database ./team.rl --read-only 2>/dev/null
{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"{\"Person\":{\"count\":3,\"properties\":{\"name\":{\"type\":\"String\"},\"role\":{\"type\":\"String\"}},\"relationships\":{\"KNOWS\":{\"direction\":\"out\",\"labels\":[\"Person\"]},\"MAINTAINS\":{\"direction\":\"out\",\"labels\":[\"Project\"]},\"WORKS_ON\":{\"direction\":\"out\",\"labels\":[\"Project\"],\"properties\":{\"since\":{\"type\":\"Integer\"}}}},\"type\":\"node\"},\"Project\":{\"count\":2,\"properties\":{\"language\":{\"type\":\"String\"},\"name\":{\"type\":\"String\"}},\"type\":\"node\"}}","type":"text"}],"isError":false}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\"Person\":{\"count\":3,\"properties\":{\"name\":{\"type\":\"String\"},\"role\":{\"type\":\"String\"}},\"relationships\":{\"WORKS_ON\":{\"direction\":\"out\",\"labels\":[\"Project\"],\"properties\":{\"since\":{\"type\":\"Integer\"}}}},\"type\":\"node\"},\"Project\":{\"count\":2,\"properties\":{\"language\":{\"type\":\"String\"},\"name\":{\"type\":\"String\"}},\"type\":\"node\"}}","type":"text"}],"isError":false}}

With sample_size 1, Person still counts 3 nodes, and only one of its three relationship types was seen.

read_cypher

InputTypeDefaultMeaning
statementstringrequiredOne Cypher statement. A pure read passes, OPTIONAL MATCH included; a write or a schema statement is refused (Read-only policy)
parametersobject{}The statement's parameters, as plain JSON values; a key named rows binds $rows like any other key. A value that is neither an object nor null is refused

The result is the envelope described in Results. The timings change on every run, so this example replaces them with <ms>:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY since","parameters":{"project":"relate"}}}}' \
  | relate serve --mcp stdio --database ./team.rl --read-only 2>/dev/null \
  | sed -E 's/(_ms\\":)[0-9.e-]+/\1<ms>/g'
{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"{\"columnTypes\":[\"STRING\",\"INTEGER\"],\"columns\":[\"name\",\"since\"],\"elapsed_ms\":<ms>,\"error\":null,\"execute_ms\":<ms>,\"lex_parse_ms\":<ms>,\"lower_ms\":<ms>,\"notifications\":[{\"category\":\"PERFORMANCE\",\"code\":\"Relate.Notification.Statement.MissingIndex\",\"description\":\"the equality predicate on name filters :Project without a range index on (:Project {name})\",\"gql_status\":\"03C91\",\"operator_id\":1,\"remedy\":\"CREATE INDEX relate_Project__name FOR (n:Project) ON (n.name)\",\"severity\":\"INFORMATION\",\"title\":\"Missing index\"}],\"parse_ms\":<ms>,\"plan\":{\"profiled\":false,\"root\":{\"actual\":null,\"children\":[{\"actual\":null,\"children\":[],\"details\":\"(:Project)<-[w:WORKS_ON]-(p:Person)\",\"estimated_rows\":null,\"identifiers\":[],\"operator\":\"Expand\",\"warnings\":[{\"category\":\"PERFORMANCE\",\"code\":\"Relate.Notification.Statement.MissingIndex\",\"description\":\"the equality predicate on name filters :Project without a range index on (:Project {name})\",\"gql_status\":\"03C91\",\"remedy\":\"CREATE INDEX relate_Project__name FOR (n:Project) ON (n.name)\",\"severity\":\"INFORMATION\",\"title\":\"Missing index\"}]}],\"details\":\"2 item(s), ordered\",\"estimated_rows\":null,\"identifiers\":[],\"operator\":\"ProduceResults\",\"warnings\":[]},\"schema\":\"relate.plan/v1\",\"statement\":\"MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) RETURN p.name AS name, w.since AS since ORDER BY since\"},\"plan_cache\":\"miss\",\"plan_ms\":<ms>,\"result_generation_ms\":<ms>,\"rewrite_ms\":<ms>,\"rows\":[{\"name\":\"Grace\",\"since\":2023},{\"name\":\"Ada\",\"since\":2024}],\"stats\":{\"constraints_added\":0,\"constraints_removed\":0,\"indexes_added\":0,\"indexes_removed\":0,\"labels_added\":0,\"labels_removed\":0,\"nodes_created\":0,\"nodes_deleted\":0,\"properties_set\":0,\"relationships_created\":0,\"relationships_deleted\":0}}","type":"text"}],"isError":false}}

The rows are Grace since 2023 and Ada since 2024. The notification Advice the planner attaches to a plan, such as a missing index, with a GQLSTATUS code, a severity and a remedy; derived only under EXPLAIN or PROFILE. Read more in the glossary in the result comes from the plan, before the statement ran.

write_cypher

write_cypher takes the same two inputs as read_cypher and runs a statement that writes. A statement that only reads is refused with Only write queries are allowed for write-query. The result is the same envelope, with empty columns, columnTypes, and rows, and the mutation counters under stats. This example writes to a throwaway in-memory store, so the server runs without --read-only:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"CREATE (:Person {name: $name, role: $role})","parameters":{"name":"Ada","role":"engineer"}}}}' \
  | relate serve --mcp stdio --database :memory: 2>/dev/null \
  | sed -E 's/(_ms\\":)[0-9.e-]+/\1<ms>/g'
{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"{\"columnTypes\":[],\"columns\":[],\"elapsed_ms\":<ms>,\"error\":null,\"execute_ms\":<ms>,\"lex_parse_ms\":<ms>,\"lower_ms\":<ms>,\"notifications\":[],\"parse_ms\":<ms>,\"plan\":{\"profiled\":false,\"root\":{\"actual\":null,\"children\":[],\"details\":\"(:Person)\",\"estimated_rows\":null,\"identifiers\":[],\"operator\":\"Create\",\"warnings\":[]},\"schema\":\"relate.plan/v1\",\"statement\":\"CREATE (:Person {name: $name, role: $role})\"},\"plan_cache\":\"miss\",\"plan_ms\":<ms>,\"result_generation_ms\":<ms>,\"rewrite_ms\":<ms>,\"rows\":[],\"stats\":{\"constraints_added\":0,\"constraints_removed\":0,\"indexes_added\":0,\"indexes_removed\":0,\"labels_added\":1,\"labels_removed\":0,\"nodes_created\":1,\"nodes_deleted\":0,\"properties_set\":2,\"relationships_created\":0,\"relationships_deleted\":0}}","type":"text"}],"isError":false}}

Results

The text of a successful read_cypher or write_cypher call is one JSON object, the same result metadata the CLI's --mode envelope and the clients carry:

KeyValue
columnsThe column names, in order
columnTypesOne type name per column, from the first row: STRING, INTEGER, FLOAT, BOOLEAN, NODE, RELATIONSHIP, PATH, LIST, MAP, POINT, DATETIME, DATE, TIME, DURATION, VECTOR, UUID, BINARY or ANY
rowsOne object per row, keyed by column. A node is {"id", "kind": "node", "labels", "properties"}, a relationship {"id", "kind": "relationship", "type", "start_node", "end_node", "properties"}, a date its ISO 8601 string
statsThe 11 mutation counters, zeros included: nodes_created, nodes_deleted, relationships_created, relationships_deleted, properties_set, labels_added, labels_removed, indexes_added, indexes_removed, constraints_added, constraints_removed
notificationsThe plan's notifications, each with code, gql_status, severity, category, title, description, remedy and, on an operator's notification, operator_id. Always present; [] when there are none
planThe plan of the statement as a relate.plan/v1 document: schema, statement, profiled (false) and root, an operator tree of operator, details, estimated_rows, identifiers, warnings, actual and children
elapsed_msThe statement's time in the engine, in milliseconds
parse_ms, plan_ms, execute_msThe parse, plan and execute parts of elapsed_ms; their sum is never more than elapsed_ms
lex_parse_ms, rewrite_ms, lower_msParts of parse_ms: lexing and parsing, the rewrite pass, and validation with lowering
result_generation_msThe part of execute_ms spent building the result
plan_cacheThe plan cache's outcome, such as miss, or hit-raw-fastpath for a statement the server planned before
errornull on a result; a failed call is a tool error instead (Errors and refusals)

A byte array, which reaches the engine only as a Bolt or Rust parameter and never through this server's parameters, is a list of integers from 0 to 255 in a result, with the column type BINARY.

Read-only policy

Before it runs a statement, each Cypher tool plans it with EXPLAIN and reads the statement's query type from the plan. That plan is the plan in the result, and its notifications are the result's notifications; a PROFILE never runs. A statement that already starts with EXPLAIN or PROFILE fails with syntax error: parse error: unexpected token "EXPLAIN" (or "PROFILE"), because the tool plans it as EXPLAIN <statement>; notifications lists what the result can carry.

Store openedTools listedread_cypherwrite_cypher
Writable (the default)write_cypher, read_cypher, get_schemaRuns a read (query type r); refuses a write (w, rw) and a schema statement (s: CREATE/DROP INDEX, CREATE/DROP CONSTRAINT)Runs a write or a schema statement; refuses a read
--read-onlyread_cypher, get_schemaThe sameNot listed; a call answers unknown tool 'write_cypher'
--read-only=live (a directory store)read_cypher, get_schemaThe same, against the writer's latest commitsNot listed
Any, with --safeAs without itAlso refuses a MATCH read with no LIMIT and no reducing aggregate, as SafeModeViolationAlso refuses a new named node without a label

--read-only takes no writer lease and never creates the store; a missing store fails the open. --read-only=live follows a directory store that another process writes (live read-only A read-only open of a directory store, --read-only=live, that tails the WAL and follows the writer's commits as they land. Read more in the glossary), and is refused for a .rl file and for :memory:. The tools of a read-only server carry readOnlyHint: true, and hosts that approve read-only tools without asking can use that. The agent safety guide has the permission matrix across the CLI and MCP.

Errors and refusals

A tool that fails returns a result with isError: true and the message as its text; it is not a JSON-RPC error. These runs show the refusals of a read-only server:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (p:Person {name: $name}) DETACH DELETE p","parameters":{"name":"Ada"}}}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"CREATE INDEX person_name FOR (p:Person) ON (p.name)"}}}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"write_cypher","arguments":{"statement":"CREATE (:Person {name: \"Eve\"})"}}}' \
  '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"read_cypher","arguments":{"query":"MATCH (p:Person) RETURN count(p) AS people"}}}' \
  '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"RETURN $limit AS n","parameters":"10"}}}' \
  '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"RETURN $limit AS n"}}}' \
  '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"get_schema","arguments":{"sample_size":-2}}}' \
  | relate serve --mcp stdio --database ./team.rl --read-only 2>/dev/null
{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"Only MATCH queries are allowed for read-query","type":"text"}],"isError":true}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"Only MATCH queries are allowed for read-query","type":"text"}],"isError":true}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"unknown tool 'write_cypher'","type":"text"}],"isError":true}}
{"jsonrpc":"2.0","id":4,"result":{"content":[{"text":"invalid params: missing string \"statement\"","type":"text"}],"isError":true}}
{"jsonrpc":"2.0","id":5,"result":{"content":[{"text":"invalid params: \"parameters\" must be an object","type":"text"}],"isError":true}}
{"jsonrpc":"2.0","id":6,"result":{"content":[{"text":"Error: semantic error: ParameterMissing: Expected parameter(s): limit","type":"text"}],"isError":true}}
{"jsonrpc":"2.0","id":7,"result":{"content":[{"text":"invalid params: sample_size must be -1 or non-negative, got -2","type":"text"}],"isError":true}}
Tool error textWhen
Only MATCH queries are allowed for read-queryread_cypher got a write or a schema statement (errors reference)
Only write queries are allowed for write-querywrite_cypher got a read (errors reference)
unknown tool '<name>'The tool is not listed, including write_cypher on a read-only server (errors reference)
invalid params: missing string "statement"A Cypher tool's arguments has no string statement (an argument named query is not read)
invalid params: "parameters" must be an objectparameters is a string, a list, a number or a boolean; null counts as no parameters
invalid params: sample_size must be -1 or non-negative, got <n>get_schema got a negative sample_size other than -1
Error: <engine message>The engine refused the statement: a syntax error, a missing parameter, a type error, SafeModeViolation; the text after Error: is the engine's message, as on the errors reference
Error: internal errorAn engine-internal, storage, IO or lock failure; the detail stays out of the tool result (errors reference)

A request that is not a valid call gets a JSON-RPC error. A body that cannot be decoded gets "id": null:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"resources/list"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}' \
  '{"jsonrpc":"2.0","id":3,"method":"ping","extra":true}' \
  '{"jsonrpc":"1.0","id":4,"method":"ping"}' \
  'not json' \
  | relate serve --mcp stdio --database ./team.rl --read-only 2>/dev/null
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found: resources/list"}}
{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Invalid params: tools/call needs a string \"name\""}}
{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"malformed JSON-RPC message: unknown field `extra`, expected one of `jsonrpc`, `id`, `method`, `params` at line 1 column 47"}}
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"unsupported JSON-RPC version '1.0'"}}
{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"malformed JSON-RPC message: expected ident at line 1 column 2"}}
JSON-RPC codeMessageWhen
-32700malformed JSON-RPC message: <detail>The body is not JSON, lacks jsonrpc or method, carries a member other than jsonrpc, id, method and params, or is a JSON array (batches are not accepted)
-32600unsupported JSON-RPC version '<version>'jsonrpc is not "2.0"
-32601Method not found: <method>Any method outside Methods
-32602Invalid params: <detail>tools/call without a string name

On the HTTP transport, the server answers these before any JSON-RPC handling, with a JSON body of one key, error:

HTTP statusBody errorWhen
401authentication requiredNo credential or a wrong one, after a 1-second delay; with WWW-Authenticate: Basic realm="relate"
403origin is not allowedA present Origin that is not loopback
405streamable HTTP MCP is POST-onlyAny method but POST, with Allow: POST
415the request content type is browser-"simple" — a cross-origin form cannot send a non-simple type without a preflightA POST with no Content-Type, text/plain, or a form type
400unsupported HTTP version HTTP/1.0: this surface answers HTTP/1.1A request line that is not HTTP/1.1
400malformed request: body too largeA body over the 16 MiB cap
202Empty bodyA JSON-RPC notification

The 403 and 415 checks run before the credential check, and the credential check runs before the body is read.

Authentication

The HTTP transport asks for HTTP Basic authentication (RFC 7617) on every request. The stdio transport asks for none, because the host that spawned the process is the only peer.

SourceCredential
--auth USER:PASSWORDThat user and password. A value with no colon exits 1 with Error: --auth: expected user:password or 'none', and one with an empty side with Error: --auth: expected user:password with both sides non-empty
RELATE_AUTHThe same forms, read when --auth is absent
--auth noneNo credential is checked
NeitherA generated token for the user relate, printed on stderr at startup. For a directory or .rl store it is saved beside the store as <store>.auth-token (mode 0600, holding relate:<token>) and reused on restart; :memory: generates a new token on every start

The credential is the one relate serve shares with its --http and --bolt listeners, so one token opens all three. A failed check waits 1 second before the 401 goes out. A credential given as --auth on the command line is visible in the process list; RELATE_AUTH and the generated token keep it out of the arguments. Every surface is plaintext, so a credential sent to a non-loopback --host crosses the network unencrypted; Security lists the defaults.

Limits

Every row is a limit.

LimitValueOver the limit
Statements per call1A second statement after ; is a syntax error
Messages per HTTP request1A JSON array is a -32700 error
HTTP request body16 MiBHTTP 400, malformed request: body too large
HTTP header deadline10 seconds for the whole header block (--header-deadline-secs, 0 turns it off, at most 86400)The connection is dropped with no reply
HTTP body deadline60 seconds for the whole body (--body-deadline-secs, 0 turns it off, at most 86400)The connection is dropped with no reply
Concurrent connections128, shared with the --http and --bolt listeners (--max-connections)The connection is accepted, then closed
Failed credential checksOne answer per second per connectionThe 401 waits 1 second
get_schema sample1000 nodes and relationships per label by defaultTypes a smaller sample did not see are left out
Traversal depthAn explicit hop bound up to 500 (--max-traversal-depth, 1 to 2000)A tool error carrying TraversalDepthExceeded
Expression memoryOne value up to 256 MiB (--expression-memory-limit)A tool error

The server has no per-call timeout and no row cap: relate serve takes neither --timeout nor --max-rows, and a read_cypher result holds every row. A bounded read uses LIMIT, and --safe refuses an unbounded MATCH read.

Logging

The server writes its startup and shutdown lines to stderr: on stdio, serve: MCP (stdio) serving on stdin/stdout (auth: none) and, when stdin closes, serve: stdin closed, shutting down; on HTTP, serve: MCP (streamable HTTP) listening on http://<host>:<port> (auth: basic), plus an auth: line naming where the credential came from, and the generated token when there is one. On stdio, stdout carries only JSON-RPC. The runs recorded for this page wrote no line per request on either transport. An internal engine failure reaches the caller only as Error: internal error.

Differences from mcp-neo4j-cypher

The tools follow the contract of Neo4j's mcp-neo4j-cypher server under relate's own names, as relate's MCP specification records it; the specification names no version of that server, and it was not run for this page:

relatemcp-neo4j-cypherDifference
get_schemaget_neo4j_schemaNone in the input or output shape
read_cypherread_neo4j_cypherThe rows arrive inside the result envelope, under rows, with column types, notifications, the plan, and timings beside them
write_cypherwrite_neo4j_cypherThe counters arrive inside the result envelope, under stats

The refusal texts Only MATCH queries are allowed for read-query and Only write queries are allowed for write-query are the same in both.

See also

  • Connect over MCP: the configuration block for each agent host, every one opening the store --read-only
  • relate serve: every serve flag, and the HTTP Query API and Bolt listeners beside MCP
  • MCP errors: each refusal and JSON-RPC error with its cause and remedy
  • Keep an agent safe: read-only opens, safe mode, and backups before an agent writes
  • Graph queries over MCP: the problem this server solves