Relay
<p align="center">
<img src="assets/banner.svg" alt="Relay" width="100%">
</p>
<p align="center">
<a href="https://github.com/ahirmaan/relay-graph/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/ahirmaan/relay-graph/actions/workflows/ci.yml/badge.svg"></a>
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-3ddc97">
<img alt="Node 20+" src="https://img.shields.io/badge/node-20%2B-8b7bff">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-strict-5b8def">
<img alt="FalkorDB" src="https://img.shields.io/badge/graph-FalkorDB-e0457b">
<img alt="MCP" src="https://img.shields.io/badge/protocol-MCP-f5a524">
</p>
<p align="center">
<b>Shared, append-only memory for AI assistants, stored as a graph in FalkorDB and served over MCP.</b><br>
Built for Graph Hacks, Track 2: Agent Memory and Coordination.
</p>
---
## Why Relay
Claude Desktop in the morning, Cursor in the afternoon: each starts with a blank memory.
- **They forget.** A decision made yesterday is gone today.
- **They can't see each other.** One tool picks cookies, another picks Redis, and nobody notices.
- **Nobody knows what depends on what.** Change one decision and you can't tell what else breaks.
Relay gives every MCP-compatible assistant **one shared memory**. Facts are never overwritten, only added, and each new fact links to the fact it builds on. That graph is what lets Relay find disagreements, record how they were resolved, and show what is affected when a decision is overturned.
## See it work
<p align="center">
<img src="assets/demo.svg" alt="Claude Desktop resolves a conflict, then Cursor catches up with a briefing" width="90%">
</p>
## What you get
| Capability | How Relay does it |
|---|---|
| **Trace reasoning** | `relay_get_context` walks a fact back to its origin. `relay_get_impact` walks forward to everything built on it. |
| **Catch disagreements** | `relay_detect_conflicts` finds forks where two tools built on the same decision in different directions. |
| **Close the loop** | `relay_resolve_conflict` records the winner and why. The loser is kept, and everything built on it is flagged stale. |
| **Start sessions informed** | `relay_get_briefing` tells a tool what the others did since it last looked. |
| **Rank what matters** | `relay_get_important_facts` runs FalkorDB's PageRank to find the load-bearing decisions. |
| **Group related work** | `relay_get_clusters` runs FalkorDB's connected-components algorithm to separate lines of reasoning. |
FalkorDB is the only datastore. There is no cache and no fallback: stop the database and every tool returns an error.
## Quick start
Requirements: Node 20+ and Docker.
```bash
cp .env.example .env # then put a long random value in FALKORDB_PASSWORD (see the file for a one-liner)
docker compose up -d # FalkorDB on 127.0.0.1:6380, FalkorDB Browser on http://localhost:3000
npm install
npm run check-db # prints: Connected to FalkorDB ... RETURN 1 + 1 -> [ { two: 2 } ]
npm run build # compiles to dist/
npm run seed # loads the demo graph
```
On Windows PowerShell, use `Copy-Item .env.example .env` for the first line.
The database listens on host port **6380** (so it won't clash with a local Redis) and only on `127.0.0.1`. It requires the password from `.env`; the Relay server reads that same file, so the password never needs to go into your MCP client config. Override with `FALKORDB_HOST`, `FALKORDB_PORT`, `FALKORDB_PASSWORD` and `RELAY_GRAPH` if needed.
## Connect an MCP client
### Claude Desktop
Edit `claude_desktop_config.json` (Settings, Developer, Edit Config), then fully quit and reopen Claude Desktop. Use the absolute path to your build. Forward slashes work on Windows and avoid escaping mistakes:
```json
{
"mcpServers": {
"relay": {
"command": "node",
"args": ["C:/path/to/relay-graph/dist/server.js"],
"env": {
"FALKORDB_HOST": "localhost",
"FALKORDB_PORT": "6380"
}
}
}
}
```
On macOS or Linux, use a normal path such as `/Users/you/relay-graph/dist/server.js`.
### Cursor
Add the same `relay` entry to `~/.cursor/mcp.json` (or the project's `.cursor/mcp.json`) and restart Cursor.
### Try it
Ask your assistant things like:
- `Use Relay to check for conflicts.`
- `Use Relay to resolve that: Redis wins because we need instant revocation. What is affected?`
- `Use Relay to give me a briefing. I am cursor.`
- `Save to Relay: "Add email verification at signup", building on the authentication fact.`
## How it works
Every MCP client starts its own Relay process. All of them talk to the same FalkorDB.
```mermaid
flowchart LR
CD["Claude Desktop"] -->|MCP tools| R
CU["Cursor"] -->|MCP tools| R
OT["Any MCP client"] -->|MCP tools| R
R["Relay server<br/>validation, secret scan, rate limit"] -->|parameterised Cypher| F[("FalkorDB<br/>the only datastore")]
F -.->|visualise| B["FalkorDB Browser"]
```
### Graph data model
```
(:Fact {id, content, written_by, session_id, created_at})
(new fact)-[:BUILDS_ON]->(fact it is based on)
(winning fact)-[:SUPERSEDES {reason, resolved_by, created_at}]->(losing fact)
```
```mermaid
graph BT
A["Need auth"]
B["JWT tokens"] -->|BUILDS_ON| A
C["Cookies for refresh tokens"] -->|BUILDS_ON| B
D["Redis for refresh tokens"] -->|BUILDS_ON| B
D -.->|SUPERSEDES| C
E["Rate limit login"] -->|BUILDS_ON| C
```
- **Append-only.** Facts are never updated or deleted. A change of mind is a new fact that builds on the old one.
- **Edges point from new to old.** Following `BUILDS_ON` walks back through the reasoning to the root.
- **Zero or one parent.** A fact with no `BUILDS_ON` edge is a root (an origin).
- **Many children are allowed.** Two facts building on the same parent is a **fork**, for example two tools taking different directions from one decision. This is what conflict detection looks for.
- **`SUPERSEDES` closes a fork.** It records which of two disagreeing facts won, why, and who decided. The loser stays in the graph and is marked, never deleted.
## MCP tools
**Memory and tracing**
| Tool | What it does | Graph feature used |
|---|---|---|
| `relay_save_fact` | Appends a fact, optionally linked to a parent. Warns if it creates a fork. | `CREATE`, edge match |
| `relay_get_context` | Returns the chain from the root to a given fact ("why does this exist?"). | variable-length path `[:BUILDS_ON*0..]` |
| `relay_get_impact` | Returns everything built on a fact ("what breaks if we change it?"). | the same path, traversed in reverse |
| `relay_get_current_state` | Newest facts with their decision status (superseded or stale). | `ORDER BY`, `OPTIONAL MATCH` |
**Disagreement and decisions**
| Tool | What it does | Graph feature used |
|---|---|---|
| `relay_detect_conflicts` | Finds open forks: sibling facts with the same parent, different content, and no decision yet. | pattern match, negative pattern |
| `relay_resolve_conflict` | Records which fact wins and why. The loser is kept, marked superseded, and everything built on it is reported stale. | `MERGE` of a `SUPERSEDES` edge |
**Awareness**
| Tool | What it does | Graph feature used |
|---|---|---|
| `relay_get_briefing` | Call at the start of a session: what other tools wrote or decided since you last looked, plus forks needing a decision. | time-filtered pattern queries |
| `relay_get_important_facts` | Ranks load-bearing decisions. | FalkorDB `algo.pageRank` |
| `relay_get_clusters` | Groups facts into connected reasoning threads. | FalkorDB `algo.WCC` |
All the Cypher is listed with explanations in [cypher-queries.md](cypher-queries.md).
<details>
<summary><b>Design decisions</b></summary>
- **Append-only plus `BUILDS_ON`** gives provenance for free. You can always ask why a decision exists.
- **Forks are surfaced, not blocked.** Two tools disagreeing is information, so a save always succeeds and the response flags the sibling.
- **"Meaningfully differs"** is deliberately simple: content differs after trimming and lowercasing. It is done in Cypher so the graph stays the single source of truth. Two children of the same parent with different text are always reported as a fork, even if they are compatible. The model reading the result judges whether they truly conflict.
- **Decisions have a lifecycle.** Finding a disagreement is not enough. `relay_resolve_conflict` closes it with a `SUPERSEDES` edge, and the graph then answers the follow-up question, "what was built on the losing decision?", with the same path traversal used everywhere else. Status (superseded, stale) is derived from edges at query time, never stored on nodes, so it can't drift.
- **PageRank, not edge counts.** A fact that later facts build on transitively ranks above one with only a single direct child.
- **Clusters come from structure.** Connected components group facts that are linked by `BUILDS_ON` or `SUPERSEDES`, so separate lines of reasoning split apart without anyone labelling them. These are connected threads, not semantic topics. FalkorDB also ships `algo.labelPropagation` if finer-grained communities are wanted later.
</details>
## Demo
```bash
npm run seed
```
Creates 8 facts written by `claude-desktop` and `cursor` across two threads, with one intentional fork (where refresh tokens should be stored). To see it:
1. Open **http://localhost:3000** (FalkorDB Browser). On the login screen keep host `localhost` and port `6379` (the Browser runs inside the container, so it uses 6379, not the 6380 host mapping), leave the username empty, and enter the `FALKORDB_PASSWORD` from your `.env` as the password. Select the graph `relay`, and run
`MATCH (n)-[r]->(m) RETURN n, r, m`
2. In your MCP client, try:
- `relay_detect_conflicts`: finds the refresh-token fork.
- `relay_resolve_conflict`: pick the winner, then ask for `relay_get_impact` on the loser to see what is now stale.
- `relay_get_briefing` as `cursor`: what `claude-desktop` did that Cursor hasn't seen.
- `relay_get_important_facts` and `relay_get_clusters`.
## Concurrency
Every MCP client (Claude Desktop, Cursor, ...) starts its own Relay process, and all of them talk to the same FalkorDB. FalkorDB runs write queries one at a time, so concurrent writers can't corrupt each other, and each Relay query is a single atomic Cypher statement.
- **No lost or duplicate writes:** fact ids are random UUIDs and `Fact.id` has a database-level unique constraint.
- **Idempotent decisions:** `relay_resolve_conflict` uses `MERGE`, so many simultaneous identical resolutions create exactly one `SUPERSEDES` edge.
- **One shared connection per process:** the connection is created once even if many requests arrive before it is ready, and a dropped or failed connection is retried on the next call instead of crashing the server.
`npm run test:concurrency` checks these directly: 40 parallel writes on one connection, 25 simultaneous cold-start requests, 10 racing identical resolutions, the unique-id constraint, and 3 separate server processes writing at the same time (30 writes, all landed).
## Security
Relay is built for one developer's machine with several AI tools. This is what is protected and what is not.
| Risk | What Relay does |
|---|---|
| Anyone on the network reading or writing the database | The database requires a password (from the git-ignored `.env`) and its ports are bound to `127.0.0.1` only. |
| Password leaking into configs or git | The server reads `.env` itself, so MCP client configs hold no secret. `.env` is git-ignored; `.env.example` is the template. |
| Cypher injection | Every query is parameterised. User text is never concatenated into Cypher. |
| Malformed or oversized input | Ids must be UUIDs, names are restricted to a safe character set, content is capped at 2000 characters and reasons at 500. |
| Credentials ending up in shared memory | A write scanner rejects content that looks like an API key, private key, JWT, AWS or GitHub token, or a `password=...` assignment. |
| Memory poisoning / prompt injection through stored facts | Stored text is returned with a note that it is untrusted data, not instructions. This reduces the risk; it cannot eliminate it. |
| A runaway agent loop flooding the database | Per-process rate limit, 120 calls per minute by default (`RELAY_RATE_LIMIT`). |
| Leaking internals in errors | Errors shown to the model are generic; hosts, ports and driver messages go only to the server's stderr log. |
**Not covered, on purpose:** Relay is not multi-user. Any process on your machine that has the password can read and write everything. `written_by` is a label the client supplies, not a verified identity. There is no TLS because traffic never leaves localhost, and facts are stored unencrypted. Exposing Relay over a network would need a hosted transport with per-client authentication, which this version does not include. Don't store secrets in memory even though the scanner helps catch the obvious ones.
`npm run test:security` checks the validation, the secret scanner, injection safety, the untrusted-data label, the rate limit and error hygiene. To report a vulnerability, see [SECURITY.md](SECURITY.md).
## Tests
```bash
npm run test:all # runs everything below, then re-seeds the demo graph
npm test # core chain: save_fact and get_context, fork flagging, missing parent
npm run test:features # impact analysis, conflict resolution and stale detection, session briefing
npm run test:concurrency # parallel writes, connection race, atomic resolution, multi-process
npm run test:security # validation, secret scanner, injection, rate limit, error hygiene
npm run test:mcp # re-seeds, then calls all 9 tools through a real MCP stdio connection
```
The tests wipe the `relay` graph. Don't run them against data you want to keep. CI runs the full suite against a real FalkorDB on every push.
## Project layout
```
docker-compose.yml FalkorDB container (password, localhost-only ports)
.env.example template for the database password (copy to .env)
src/db.ts the one FalkorDB connection (reads .env)
src/graph.ts every Cypher query
src/guard.ts secret scanner and rate limiter
src/server.ts MCP server: 9 tools over stdio, input validation
src/seed.ts demo data
cypher-queries.md all queries explained
assets/ animated README graphics
```
## Roadmap
Ideas, not promises:
- A hosted (HTTP) transport with per-client authentication, so a team can share one Relay.
- Facts that build on several parents, so decisions can merge as well as fork.
- Suggested links between related facts using FalkorDB's vector index.
## Contributing and license
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). Released under the [MIT License](LICENSE).
TDQS
Scored across 9 tools
Each tool has a distinct analytical lens (ancestry via get_context, recency via get_current_state, PageRank via get_important_facts, components via get_clusters, descendants via get_impact), and descriptions make these boundaries fairly clear. Minor overlap exists between get_current_state and get_briefing (both surface recent facts) and between get_context and get_impact (ancestors vs descendants), but the text disambiguates them.
All tools share the relay_ prefix and follow a consistent verb_noun pattern (save_fact, get_context, detect_conflicts, resolve_conflict, get_briefing). No mixing of conventions or stray verbs.
Nine tools is well-scoped for a graph-based shared-memory server: one write, several read/analytics lenses, and a conflict detect/resolve pair. Every tool earns its place without redundancy.
The surface covers the full lifecycle: write, contextual/recency/graph reads, conflict detection, and conflict resolution with staleness propagation, which is strong given the intentional append-only, no-delete design. The notable gap is content/keyword search — facts can only be retrieved by graph position or recency, not by text.