agent-graph-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@agent-graph-mcpShow me what we knew about src/marketplace.js a week ago."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
agent-graph-memory
A growable, time-aware memory graph for AI agents. One SQLite file, zero runtime dependencies — or one Postgres schema per graph when you have a Postgres. Use it from JavaScript, from the command line, plug it into any MCP-capable agent (Claude Code, Codex, …), or run it as an HTTP sidecar serving many graphs.
The problem it solves
When several agents work in parallel, each one produces knowledge — what it touched, what it concluded, what blocked it. Without a shared structure, the next round can't find the part that is relevant to this task, and a manager can only see results (tickets, PRs), never the process: which files someone touched across the last three rounds, where a twice-returned task got stuck, whether someone is editing the same files right now.
Most agent-memory systems answer this with a flat list of "facts" ranked by recency, or with a vector index that can only return what you already knew to ask about. Neither holds relations, and neither lets the agent say which slice of time it wants.
agent-graph is the missing layer:
Untyped nodes and edges. A ticket, a run, a file, a person, a conclusion — each is a node with a free-string
kind. Any node can link to any node with a free-stringrel. No fixed hierarchy, no fixed depth.Edges are assertions, not facts. Each edge has its own id, an
origin(who asserted it), aprovenance(observedby a runtime vsclaimedby an agent), an optionalscope(branch, version, environment), and two time axes. The same relation can be asserted twice, retired, and re-asserted. Nothing is ever deleted —supersede()stamps the old edge and records the replacement.Two time axes, both yours to pick.
validAtasks "what was true at this instant";asOfasks "what did the graph know at this instant" — for edges and for nodes (you get each node as the version current then, and a node first recorded later does not exist yet). They are independent parameters, not something inferred from prose.Retrieval by weighted distance. Give it entry points and a cost budget; it walks the graph and returns every node within reach, with the exact path it took. Edge costs are yours: "touched the same file" might cost 1, "same repository" might cost 10, so a hub node can't drag the whole project in.
Batch queries. One call, many questions, one database load.
Embeddings only where you say. Optional, and you decide which kinds and which text get a vector — see Embeddings.
Related MCP server: agent-shared-memory
Install
npm install @zibby/agent-graph-memory
npm install pg # only if you want the Postgres driverRequires Node ≥ 22.13 (uses the built-in node:sqlite).
JavaScript
Every method returns a Promise — the same handle sits over SQLite or Postgres (see Storage drivers).
import { openGraph } from '@zibby/agent-graph-memory';
// `trusted` because this code IS the runtime: it may record what it observed.
// A handle without it (an agent's MCP session) can only write `claimed`.
const g = await openGraph('./memory.sqlite', { origin: 'run:7f3a', trusted: true });
// Every endpoint must exist before it can be linked — ids are yours to mint.
await g.put({ id: 'run:7f3a', kind: 'run', label: 'developer on ticket 292', provenance: 'observed' });
await g.put({ id: 'ticket:292', kind: 'ticket', label: 'Remove stale release note', provenance: 'observed' });
await g.put({ id: 'file:repo/src/marketplace.js', kind: 'file', label: 'src/marketplace.js', provenance: 'observed' });
await g.link({ src: 'run:7f3a', dst: 'file:repo/src/marketplace.js', rel: 'touched', cost: 1, provenance: 'observed' });
await g.link({ src: 'run:7f3a', dst: 'ticket:292', rel: 'worked_on', provenance: 'observed' });
// An agent's own conclusion — claimed, scoped to a branch:
await g.link({
src: 'run:7f3a', dst: 'file:repo/src/marketplace.js', rel: 'notes',
scope: 'main', provenance: 'claimed',
attrs: { text: 'Two releaseNotes declarations; the later one wins at runtime.' },
});
// Who else touched the files this ticket plans to touch?
const r = await g.recall({ seeds: ['ticket:292'], maxCost: 2, rels: ['worked_on', 'touched'] });
for (const hit of r.hits) console.log(hit.node.id, hit.cost, hit.path.map(e => e.rel));
// What did we know a week ago? (edges as then known, nodes as then described)
await g.recall({ seeds: ['file:repo/src/marketplace.js'], asOf: Date.now() - 7 * 864e5 });
// Several questions, one load. Hits carry `nodeId`; the record is in `nodes`, once.
const many = await g.recallMany([
{ seeds: ['ticket:292'], maxCost: 2 },
{ match: { kind: 'file', labelContains: 'marketplace' }, maxCost: 1, order: 'recent' },
]);
for (const hit of many.results[0].hits) console.log(many.nodes[hit.nodeId].label, hit.cost);Command line
agent-graph --db memory.sqlite --origin run:7f3a --trusted put '{"id":"ticket:292","kind":"ticket","label":"…","provenance":"observed"}'
agent-graph --db memory.sqlite --origin run:7f3a --trusted link '{"src":"run:7f3a","dst":"ticket:292","rel":"worked_on","provenance":"observed"}'
agent-graph --db memory.sqlite recall '{"seeds":["ticket:292"],"maxCost":2}'
agent-graph --db memory.sqlite trace ticket:292
agent-graph --db memory.sqlite subgraph '{"seeds":["ticket:292"],"maxCost":2}'
agent-graph --db memory.sqlite stats
echo '{"queries":[{"seeds":["ticket:292"]},{"match":{"kind":"file"}}]}' | agent-graph --db memory.sqlite recall-manyagent-graph --db <path> [--origin <o>] [--privileged] [--trusted] [--read-only] <command> [json]Commands: put, link, supersede, match, get, recall, recall-many, subgraph, trace, trace-edge, stats, reembed — the same operations as the MCP tools below. Each takes one JSON object (get, trace and trace-edge also accept a bare id); when the argument is omitted and stdin is not a terminal, the JSON is read from stdin. Arguments are validated against the tool's schema before anything runs (recordedAt: "yesterday" is a ValidationError naming the field). Results are pretty-printed JSON on stdout; a failure is {"error":{"name","message"}} on stderr with exit status 1, so a shell-driven agent can tell a GuardError from a PermissionError from a ValidationError from a typo. --origin stamps every write; --trusted allows provenance: "observed" (for a runtime, not a model); --read-only refuses the write commands.
MCP (Claude Code, Codex, anything that speaks MCP)
The package ships an MCP server over stdio.
The bin is agent-graph-mcp, shipped inside the agent-graph package, so npx needs --package.
Claude Code — .mcp.json in your project (or claude mcp add):
{
"mcpServers": {
"memory": {
"command": "npx",
"args": ["-y", "--package=@zibby/agent-graph-memory", "agent-graph-mcp", "--db", "./memory.sqlite", "--origin", "claude"]
}
}
}Codex — ~/.codex/config.toml:
[mcp_servers.memory]
command = "npx"
args = ["-y", "--package=@zibby/agent-graph-memory", "agent-graph-mcp", "--db", "./memory.sqlite", "--origin", "codex"]Add --read-only to expose only the read tools, or --trusted when the server acts for a runtime that may record provenance: "observed" — by default an agent-driven session can only write claimed.
Tools exposed: graph_put, graph_link, graph_supersede, graph_reembed (writes) and graph_match, graph_get, graph_recall, graph_recall_many, graph_subgraph, graph_trace, graph_trace_edge, graph_stats (reads). Every write is stamped with --origin; the tool descriptions explain the two time axes (validAt, asOf, recordedBetween) so a model can pick the slice it needs, and every call is validated against the same schema the model was shown. The server is hand-rolled newline-delimited JSON-RPC over stdio (no SDK dependency), speaks protocol versions 2024-11-05 through 2025-06-18, and returns tool failures — guard rejections, permission refusals and schema violations included — as isError results the model can read and recover from.
Summaries first, payloads on demand
A recall can return project: 'summary' hits — id, kind, label, provenance, time, and the path as relation names — instead of whole records. The CLI, MCP and HTTP surfaces default to summary because an agent pays per token and mostly needs to decide what to look at; it then fetches a node's attrs by id with graph_get / graph_trace, or asks project: 'full' when it really wants the payloads. The JS API keeps full as its default.
The model, in one paragraph
A node is { id, kind, label, attrs }; put() on an existing id appends a version, so history is kept. An edge is { src, dst, rel, cost, directed, scope, attrs, origin, provenance, validFrom, validTo, recordedAt, supersededAt, supersedes }. recall() resolves entry points (by id, by exact match, or by free text through the configured embeddings or a Locator you supply), then runs a bounded cheapest-path walk, filtering edges by time, scope, relation and provenance, and returns each reachable node with its cost and path. recallMany() runs several of those on one load; its hits carry nodeId and the shared nodes map holds each record once. trace() gives a node's versions and every edge ever attached to it; traceEdge() follows a supersession chain.
World time defaults. An edge with no validFrom is unbounded into the past: a fact asserted now may well have held before anyone recorded it, so a validAt query at any earlier instant still finds it. "Starts now" is not a default — pass validFrom: Date.now() when you mean it. No validTo means the fact still holds (an open edit window, a running job).
Visualisation
subgraph(query) is the renderer's input: the nodes a recall() with the same query would reach, plus every live edge among them, not only the cheapest paths — "pick an area to view". Slice it with recordedBetween to animate how an area grew round by round; asOf replays what was believed at a point in time. Ids, kinds and rels are stable strings, so colour and shape can key on them. No renderer ships in v1; the CLI (subgraph) and the MCP tool (graph_subgraph) both export it as JSON.
Example: four questions a fleet manager asks
npm run example builds a small fleet in memory — three members, three rounds of runs, tickets, files, one twice-returned ticket, one corrected claim, one run still editing — and answers, side by side with hand-written SQL over a flat events table built from the same facts:
Which files did member X touch across the last three rounds, and do they overlap the files ticket T plans to touch?
T was returned twice — where did it get stuck each time, by which run, when?
T depends on D — who worked on D last round, and what was the outcome?
Is anyone editing, right now, the files T plans to touch? (
validAt: now— closed edit windows fall out of the walk.)
Both give the same answer; the graph one also carries the path walked, each hop's provenance and origin, and, for the corrected claim, the round-2 view (asOf) next to the live one. Source: examples/fleet/.
Hosts without a persistent disk
Some hosts (a serverless function, a container that is recycled, a sandbox) cannot keep the SQLite file. The graph therefore exposes its writes:
import { openGraph, journalToSql } from '@zibby/agent-graph-memory';
// 1. Journal: every successful write, after commit, in commit order.
const g = await openGraph(':memory:', {
origin: 'run:7f3a', trusted: true,
journal: (op) => appendToDurableLog(JSON.stringify(op)), // your append-only store
});
// 2. Replay at startup — needs a privileged AND trusted handle, because it writes
// other origins' records verbatim (their origin, provenance, recordedAt, ids).
const boot = await openGraph(':memory:', { origin: 'boot', privileged: true });
const { applied, skipped } = await boot.replay(readWholeLog().map(JSON.parse));Every op is explicit — put carries the version as stored (merged attrs included), link and supersede carry the edge ids the graph minted — so a replay on any database reproduces the same rows. Replay is idempotent: an op already present (edge id exists, that put version already recorded, edge already superseded) is counted as skipped, so overlapping journals and re-runs are safe, and two writers' journals can be merged by recordedAt and replayed in either merge order. Guards still run (a journaled credential is refused with the op's index; ops before it stay applied, so fix the log and replay again). The journal hook is not called during a replay.
Two more shapes for the same need. journalToSql(op, dialect?) renders an op as the exact INSERT/UPDATE statements the store itself runs ('sqlite' by default, 'postgres' for $n placeholders), for a host that keeps a remote SQL copy and would rather apply the writes there than hold a log. And dump() / load(dump) move whole tables as rows: load is privileged, atomic, guarded, and idempotent (append-only tables INSERT OR IGNORE by primary key; nodes keeps the higher version), so a host can hydrate from rows fetched elsewhere and write rows back. Vectors are derived data and travel with neither — reembed() rebuilds them on the copy.
Storage drivers
The store speaks one dialect of SQL and hands every statement to a driver; the driver renders it for its engine and runs it. Two ship:
SQLite (default) | Postgres | |
Open |
|
|
Dependencies | none ( | the |
Unit of a graph | one file | one schema ( |
Embeddings | float32 blobs, cosine in JS | pgvector |
Concurrency | one connection, statements serialised; each write is one transaction | one |
const g = await openGraph(
{ driver: 'postgres', connectionString: 'postgres://user:pw@db/app', schema: 'tenant_42' },
{ origin: 'run:7f3a', trusted: true },
);The schema DDL and every write statement have one definition (schema.ts); renderSql(sql, dialect) is exported so a host applying journalToSql output to its own database can render for either engine. A dump taken on one engine loads on the other — epochs are numbers, JSON is text, booleans are 0/1 on both, and Postgres text columns use COLLATE "C" so ORDER BY id agrees with SQLite's byte order.
Embeddings: you decide what gets embedded
Off by default. Turn it on by giving the graph a model:
import { openGraph, openAiCompatibleEmbedder } from '@zibby/agent-graph-memory';
const model = openAiCompatibleEmbedder({ baseUrl: 'http://localhost:11434', model: 'nomic-embed-text', dims: 768 }); // Ollama; OpenAI and vLLM speak the same shape
const g = await openGraph('./memory.sqlite', {
origin: 'run:7f3a', trusted: true,
embedding: {
...model,
kinds: ['note', 'ticket'], // only these kinds get a vector
text: (n) => (n.attrs.summary ? `${n.label}\n${n.attrs.summary}` : n.label), // what the vector is OF; return null to skip a node
maxChars: 1000, // cut before embedding
},
});Why three knobs. Embedding every node of a busy graph costs real money and latency for very little: a run, a file path, a repo are found by id or exact match, and only a few kinds carry prose worth a vector. So the core embeds nothing you did not select — kinds picks the kinds, text picks the words (default: the label only), maxChars (default 2000) caps the size — and a large graph can keep its vector table small and its model bill flat.
put()embeds the new version after it commits. A failed model call is logged and never fails the write: a model that is down loses a vector, not a fact.recall({ locate: 'rounding rule in pricing' })andmatch({ semantic: '…', kind: 'note' })search by similarity; the query text goes through the sameembed.asOfstill applies (a node that did not exist then cannot be found). A host-suppliedLocatortakes precedence over the built-in search when both are configured.reembed({ kinds?, since? })recomputes vectors under the current rule — the way to change what is embedded after the fact, or to backfill nodes written before embeddings were on. Nodes the rule now excludes have their vector removed.Storage is one row per node in
node_vectors, the vector of the version it was computed from. SQLite: brute-force cosine over the stored vectors of the requested kinds (fine at the sizes an agent memory reaches). Postgres with pgvector:ORDER BY embedding <=> $q LIMIT k.openAiCompatibleEmbedder({ baseUrl, apiKey?, model, dims })is the one built-in provider: a POST to/v1/embeddings, no SDK. It refuses a response whose dimension is notdims, so a misconfigured model cannot fill the table with incomparable rows. Any{ embed(texts) => Promise<number[][]>, dims }works in its place.
Server mode
agent-graph-server is what a sidecar runs: plain node:http, one process, many graphs.
agent-graph-server --driver sqlite --sqlite-dir /data/graphs [--port 8787] [--host 127.0.0.1] [--auth-token …]
agent-graph-server --driver postgres --pg postgres://user:pw@db/app [--port …] [--auth-token …]Route | Body | Result |
| — |
|
|
| the tool result as JSON |
<op> is a tool name without its graph_ prefix: put, link, supersede, match, get, recall, recall_many, subgraph, trace, trace_edge, stats, reembed, plus drop (deletes the graph's file or schema; requires confirm: true). The tool arguments are validated by the same schema the CLI and MCP server use — there is one definition of the tool surface.
graphId(required,^[A-Za-z0-9_.:-]{1,128}$) selects the storage:<dir>/<graphId>.sqlite, or the Postgres schema named after it (ids longer than 63 bytes get a prefix + hash). The server does not know whose graph it is — the caller derived the id and is trusted to have done so.originnames the writer. A write without one is refused (400); a read needs none.embedding—{ baseUrl, apiKey?, model, dims, text?: 'label' | 'label+attrs' | { attrs: [...] }, kinds?, maxChars? }— is per request, never per process: one server serves tenants with different models. Graphs are cached per (graphId, embedding fingerprint).Errors are
{ error: { name, message } }: 400ValidationError/GuardError, 403PermissionError, 404 unknown op, 401 bad bearer token, 500 anything else. On start the process prints one line{"listening":{host,port,driver,version}}to stdout.
MCP or HTTP? The MCP server is for an agent holding one graph over stdio: the model sees the tools and calls them. The HTTP server is for a platform fronting many graphs: it derives graphId, decides trusted/privileged, holds the model credentials, and calls the same operations over the network. Both are thin over tools.ts.
What it deliberately does not do
No vector index in the core beyond what the engine gives (pgvector's exact
<=>scan; brute force on SQLite). An HNSW index is a later, measured change.No PageRank, no strengthening, no decay. Ranking is cost and time only, so every result is explainable by its path. Access counts are recorded for a later, evaluated version.
No entity extraction. You mint node ids; the framework does not read prose and guess.
Changelog
0.2.0 — Storage is a driver: SQLite (default) or Postgres (
pg, optional). EveryGraphmethod is nowasyncandopenGraph()returns a Promise; each write is one transaction. Configurable embeddings (embeddingoption,reembed(),recall({ locate }),match({ semantic }),openAiCompatibleEmbedder).agent-graph-serverHTTP sidecar.dropGraph(),renderSql(),journalToSql(op, dialect).0.1.0 — First release: SQLite, JS API, CLI, MCP server, journal/replay, dump/load.
Safety
Values that look like credentials (
sk-…,ghp_…,Bearer …, AWS keys, …) are rejected, not stored — the write throws, in every persisted string (id, kind, label, rel, scope, origin, attrs), and the error names the field and the pattern class, never the value.Labels that read as instructions to a model ("ignore previous…", "you must…") are accepted but flagged, so a reader can tell.
provenance: 'observed'is a trusted claim: only a handle openedtrusted: true(or a binary started with--trusted) may write it. An agent's session writesclaimed, so a model cannot launder its conclusion into evidence.A handle opened as one
origincannot supersede another origin's edges or re-label another origin's nodes; on someone else's node it may only add attrs whose keys are not there yet, and it never inherits their provenance. Open withprivileged: truefor maintenance.as()only narrows: read-only is inherited and cannot be cleared,privilegedneeds a privileged parent,trustedneeds a trusted or privileged parent, and only a privileged handle may derive a handle for a different origin. A read-only handle never writes — not even access counts.
License
MIT.
This server cannot be deployed
Maintenance
Related MCP Connectors
- memnodeOAuthdev.memnode
Persistent, inspectable memory for AI agents with lineage, correction, and a hosted MCP endpoint.
Persistent memory for AI agents — log and recall conversation context over MCP.
Persistent, portable memory for AI assistants — your private memory graph, from any MCP client.
Shared, governed long-term memory for AI agents across tools and sessions via MCP and REST.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables AI agents to persist and retrieve structured thinking graphs using SQLite-backed memory with support for CRUD operations, graph search, and path finding.1-
- FlicenseNot gradedqualityCmaintenanceEnables AI agents to share persistent memory via an MCP server using SQLite, supporting multi-tenant, categorized knowledge with TTL and semantic links, without requiring vector databases.-
- AlicenseNot gradedqualityFmaintenanceLocal-first, auditable memory for AI agents. Provides durable context for MCP hosts with SQLite storage, CLI, and MCP tools for memory management.2Apache 2.0
- AlicenseNot gradedqualityCmaintenanceA bi-temporal, provenance-carrying memory primitive for AI agents. Enables storing facts, recall, revision, and audit trails via MCP with SQLite storage.6Apache 2.0