Use relate with the Claude Agent SDK
Register a read-only relate MCP server with the Claude Agent SDK, and check the tools and rows the agent gets before the first model call.
mcpcliagents
Before you start: Install relate
This guide shows you how to give an agent built with the Claude Agent SDK read access to a relate graph. relate serves a store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary 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 on stdio with --read-only. The SDK starts that server as a child process, and the agent queries the graph through relate's read_cypher tool. Every step before the model call runs without a Claude credential, so you can check what the agent gets before it makes one.
In words: Your program calls the Claude Agent SDK, which starts relate serve as a child process and talks MCP to it over stdin and stdout. relate serve opens the team store read-only and answers read_cypher and get_schema calls. A separate writer, such as your app, can keep writing the directory store; the server reads it as of its start, or follows it with --read-only=live.
Prerequisites
- relate on your
PATH(Install relate). - The Claude Agent SDK:
pip install claude-agent-sdk(Python 3.10 or later) ornpm install @anthropic-ai/claude-agent-sdk(Node.js 18.14 or later). The SDK programs in Register the server with the SDK were checked againstclaude-agent-sdk0.2.179 and@anthropic-ai/claude-agent-sdk0.3.319, as of 2026-11-07. The SDK changes faster than relate, so its own documentation wins if a name differs. - A Claude credential for the model call, such as
ANTHROPIC_API_KEYin the environment. Only the agent program in Register the server with the SDK needs it.
Create the store
The agent reads ./team, a directory store A store kept as a folder of snapshot files, a WAL folder and lock files; the layout for any --database path that is not :memory: and does not end in .rl. Read more in the glossary holding the small team graph the Get started pages use. A directory store lets your app keep writing while a 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 server reads it; a single-file .rl store that a writer holds refuses the read-only open. Plain --read-only serves the store as it was when the server started, so the agent sees later writes only after the server restarts. --read-only=live follows the writer's commits on a directory store; it refuses a .rl store and :memory:.
Write the seed script:
cat > team.cypher <<'EOF'
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);
EOFRun it into the store. This step writes, so it opens ./team without --read-only:
relate query --database ./team --mode envelope < team.cypher{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":1.769083,"execute_ms":1.4763330000000001,"lex_parse_ms":0.015375000000000002,"lower_ms":0.018000000000000002,"parse_ms":0.277375,"plan_ms":0.009708,"result_generation_ms":0.00020800000000000001,"rewrite_ms":0.00045900000000000004},"truncated":false}The CREATE returns no rows; the store now holds three people and two projects.
Check the tools the agent sees
relate serve --mcp stdio speaks MCP's JSON-RPC on stdin and stdout, one message per line, until stdin closes. It is the command the SDK starts. Send it the opening messages an MCP host sends:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| relate serve --mcp stdio --database ./team --read-only 2>/dev/null{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"relate-mcp","version":"0.1.2"}}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"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"}]}}Opened with --read-only, the server lists two tools, read_cypher and get_schema, both annotated readOnlyHint: true. write_cypher is not listed at all. The startup banner goes to stderr, which 2>/dev/null drops, so stdout carries nothing but JSON-RPC.
Call the query tool
An agent's query is a tools/call request for read_cypher, with a statement and its parameters. The response also carries timings that change on every run, so this check keeps only the rows:
printf '%s\n' \
'{"jsonrpc":"2.0","id":3,"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 --read-only 2>/dev/null \
| grep -o '\\"rows\\":\[[^]]*\]'\"rows\":[{\"name\":\"Grace\",\"since\":2023},{\"name\":\"Ada\",\"since\":2024}]Two people work on relate: Grace since 2023 and Ada since 2024. The whole response also carries the columns, column types, statistics, notifications, the plan, and timings.
A write sent to the same tool comes back as a tool error, with isError: true, and the store is unchanged:
printf '%s\n' \
'{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (p:Person {name: $name}) DETACH DELETE p","parameters":{"name":"Ada"}}}}' \
| relate serve --mcp stdio --database ./team --read-only 2>/dev/null{"jsonrpc":"2.0","id":4,"result":{"content":[{"text":"Only MATCH queries are allowed for read-query","type":"text"}],"isError":true}}Register the server with the SDK
The SDK starts each stdio entry of its MCP server map as a child process. It names that server's tools mcp__<server>__<tool>, so relate's tools arrive as mcp__relate__read_cypher and mcp__relate__get_schema.
Build the options first. Constructing them makes no model call and needs no credential. The Python SDK raises TypeError on an option name it doesn't know, so this program checks every name before the agent runs:
from claude_agent_sdk import ClaudeAgentOptions
options = ClaudeAgentOptions(
mcp_servers={
"relate": {
"type": "stdio",
"command": "relate",
"args": ["serve", "--mcp", "stdio", "--database", "./team", "--read-only"],
}
},
tools=[],
allowed_tools=["mcp__relate__get_schema", "mcp__relate__read_cypher"],
permission_mode="dontAsk",
strict_mcp_config=True,
)
if __name__ == "__main__":
print(" ".join([options.mcp_servers["relate"]["command"], *options.mcp_servers["relate"]["args"]]))
print(options.allowed_tools)
print(options.tools, options.permission_mode, options.strict_mcp_config)relate serve --mcp stdio --database ./team --read-only ['mcp__relate__get_schema', 'mcp__relate__read_cypher'] [] dontAsk True
The agent program uses the same options and makes the model call, so it needs the credential from the prerequisites. The Python version imports them from options.py:
Python
import asyncio
from claude_agent_sdk import ResultMessage, query
from options import options
async def main() -> None:
async for message in query(prompt="Who works on the relate project, and since when?", options=options):
if isinstance(message, ResultMessage):
print(message.result)
asyncio.run(main())Node.js
import { query } from "@anthropic-ai/claude-agent-sdk";
const options = {
mcpServers: {
relate: {
type: "stdio",
command: "relate",
args: ["serve", "--mcp", "stdio", "--database", "./team", "--read-only"],
},
},
tools: [],
allowedTools: ["mcp__relate__get_schema", "mcp__relate__read_cypher"],
permissionMode: "dontAsk",
strictMcpConfig: true,
};
for await (const message of query({ prompt: "Who works on the relate project, and since when?", options })) {
if (message.type === "result" && message.subtype === "success") console.log(message.result);
}To allow writes, remove --read-only; the server then also lists write_cypher, which the SDK names mcp__relate__write_cypher.
The other options keep the agent to relate's two tools:
| Python option | Node.js option | Effect |
|---|---|---|
tools=[] | tools: [] | Turns off the SDK's built-in tools (shell, file reads and edits), so relate's tools are the only ones |
allowed_tools | allowedTools | Pre-approves the listed tools, so they run without a permission prompt |
permission_mode="dontAsk" | permissionMode: "dontAsk" | Denies a call to any tool that is not pre-approved, instead of prompting |
strict_mcp_config=True | strictMcpConfig: true | Ignores MCP servers configured elsewhere, such as a project .mcp.json |
Each query the agent makes is the tools/call request from Call the query tool, so the rows it reads are the rows that check printed.
Replay the agent's calls
An agent run with these options touches the graph only through tools/call requests: the model picks one of the two tools and its arguments, the SDK sends the request to the server, and the reply goes back to the model as the tool's result. The trace below replays the calls this page's question needs, one at a time, in the order this page checks them: the schema, the query from Call the query tool, the delete that tool refuses, and the count from Verify. It is a replay of those calls, not a transcript of a model run. No model chose them, and a model given the same prompt may make fewer calls, or other ones.
The trace runs in your browser, on an in-memory store holding the same graph as ./team. This cell builds it with the statement from team.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)(no rows) · nodes created: 5, relationships created: 5, properties set: 13, labels added: 5
Each step shows the request the SDK sends for mcp__relate__<tool> and what relate's tool returns for it, built from the engine's result:
The trace starts on the database the cells up to team build. Each step below ran when the page was recorded, one after another on that database, through the mapping the MCP tryout uses; each result is what relate serve's tool returns for that call, built from the engine's result. No agent and no server took part in the recording.
Step 1 of 4: get_schema. mcp__relate__get_schema with no arguments: the labels, their property keys and types, and the relationship types between them, which is what the agent can read about the graph before it writes a statement.
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema","arguments":{}}}| Label | Nodes | Properties | Relationships out |
|---|---|---|---|
| Person | 3 | name (String), role (String) | WORKS_ON to Project; KNOWS to Person; MAINTAINS to Project |
| Project | 2 | language (String), name (String) | - |
Step 2 of 4: read_cypher. mcp__relate__read_cypher with the statement and $project of the request in Call the query tool: who works on relate, and since when.
{"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"}}}}| name | since |
|---|---|
| Grace | 2023 |
| Ada | 2024 |
Step 3 of 4: read_cypher. The delete sent to read_cypher in Call the query tool. The server's gate reads the statement's plan, finds a write, and answers with a tool error; the statement never runs.
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (p:Person {name: $name}) DETACH DELETE p","parameters":{"name":"Ada"}}}}Tool error: Only MATCH queries are allowed for read-query.
Step 4 of 4: read_cypher. The count from Verify, sent as a tool call: the three people are all still there after the refused delete.
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"read_cypher","arguments":{"statement":"MATCH (p:Person) RETURN count(p) AS people"}}}| people |
|---|
| 3 |
Live: Step sends the next call and Play all sends the rest, each to the page's database in your browser on the store the cells build up to the trace's starting cell, and each step shows the result relate serve's tool returns for that call, built from the engine's live result; a trace that would write twice starts on an emptied database, and the database is emptied again when the trace ends, so the page's cells run on what they write.
The second step's rows are the ones Call the query tool printed from the server, and the third step's error is the same text. A server opened with --read-only lists no write_cypher, so the trace has no write that succeeds.
Verify
The agent's calls leave the store as it was. A read-only count from the CLI shows all three people, Ada included, after the refused delete:
relate --database ./team --read-only --mode envelope "MATCH (p:Person) RETURN count(p) AS people"{"columnTypes":["INTEGER"],"columns":["people"],"notifications":[],"planCache":"miss","rows":[{"people":3}],"timings":{"elapsed_ms":0.31437499999999996,"execute_ms":0.14966600000000002,"lex_parse_ms":0.028709,"lower_ms":0.0425,"parse_ms":0.105292,"plan_ms":0.051417,"result_generation_ms":0.00020800000000000001,"rewrite_ms":0.00825},"truncated":false}If it goes wrong
| Message | Cause | Remedy |
|---|---|---|
Only MATCH queries are allowed for read-query (a tool error) | The agent sent a write or a schema statement to read_cypher | Expected on a read-only server. To let the agent write, serve the store without --read-only and add mcp__relate__write_cypher to the allowed tools. |
Error: semantic error: ParameterMissing: Expected parameter(s): name (a tool error) | The statement uses $name, and the call's parameters has no name | Pass every $parameter in the call's parameters object. |
Error: Failed to open Relate: storage error: read-only open requires an existing database at <path> on the server's stderr, exit code 1 | --database names a store that doesn't exist; a read-only open never creates one | Create the store first, or fix the path. A relative path resolves against the directory the server starts in, so an absolute path avoids surprises. |
Error: Failed to open Relate: storage locked: <path> on the server's stderr, exit code 1 | --database names a single-file .rl store that another process holds for writing | Serve a directory store, which allows a read-only open beside a writer, or stop the writer first. |
| The agent's answers miss recent writes (no error) | Plain --read-only serves the store as it was when the server started | Serve a directory store with --read-only=live, or restart the server. |
The errors reference lists every MCP refusal, with its JSON-RPC code where it has one.
Related
- Connect over MCP: the same server for Claude Code, Cursor, and other agent hosts.
- Keep an agent safe: read-only opens, safe mode, backups, and the permission matrix.
- CLI reference:
relate serveand every other command, with their flags. - Agent memory: a worked example of a graph an agent writes to and recalls from.