MCP server

kglite-mcp-server exposes a KGLite graph over MCP stdio. The same Rust server is available from cargo install kglite-mcp-server and inside the kglite Python wheel.

kglite-mcp-server --graph /data/graph.kgl
kglite-mcp-server --selftest --graph /data/graph.kgl

The default is read-only and registers ping, graph_overview, and cypher_query. A manifest can add source-root tools, parameterized Cypher, skills, value codecs, an embedder, and CSV-over-localhost export. Point MCP clients at the absolute executable path to avoid an older PATH-shadowing installation.

Pinning the tool surface

What a server exposes is the union of everything that registered: framework builtins, the source tools the mode binds, KGLite’s graph tools, manifest Cypher tools, and routes that appear from a dependency or a mode change without the manifest ever naming them. extensions.tools_allow inverts that: name the tools the deployment is meant to expose, and everything else is hidden.

One long-standing case of this was closed upstream in mcp-methods 0.4.5: an ambient GITHUB_TOKEN exported for unrelated reasons used to add the GitHub tools to a server whose manifest never mentions GitHub. They now register only when the manifest opts in with builtins.github: true. That removes one route at the source; the allowlist is what bounds the rest.

# /data/graph_mcp.yaml
name: My Graph
extensions:
  tools_allow:
    - cypher_query
    - graph_overview
    - ping

That server lists exactly those three tools, in every environment, and a route arriving later — from a new dependency, an exported credential, or a mode change — cannot widen the surface without an edit to the list. Hidden tools are unlisted and rejected when called by name.

Details worth knowing before writing one:

  • Names are the final, agent-visible ones. A tools: override that renames ping to domain_ping means the allowlist must say domain_ping.

  • Naming a tool that is not registered in this boot is harmless. Conditional routes (github_api without the builtins.github opt-in or without a token, load_graph on a read-only server, explore on a non-code graph) can be listed safely, so one manifest works across environments.

  • It only removes. Listing a tool some other rule hid — repo_management in a local workspace, a hidden: true override — does not bring it back.

  • The list is the whole surface, not an addition to a default set: omit ping and the server has no ping. An explicit tools_allow: [] is taken literally and leaves no tools at all.

  • A manifest that configures extensions.cypher_recipes must list list_recipe_queries and run_recipe_query; omitting them is refused at boot rather than serving a catalog no agent can reach.

  • A malformed value (not a list, or an element that is not a string) fails boot instead of being ignored — an allowlist that silently fails open is worse than none.

Query deadlines

Every query this server runs has a 180,000 ms (three-minute) deadline, the same default the Python API applies, shared as one constant in the engine. It is a liveness property, not a preference: an agent has no cancel channel once a tool call is in flight, and a runaway read holds the active graph’s read lock — which stalls the single-flight rebuild gate every later tool call enters. One bad query would otherwise take the whole server with it.

cypher_query takes an optional timeout_ms argument that overrides it for one call. timeout_ms: 0 runs without a deadline — the escape hatch for the long analytical query the default exists to bound, used deliberately rather than by accident. The deadline applies to every route that reaches the engine: the built-in tool, manifest tools[].cypher templates, and recipe queries.

row_limit — the Python knob that caps the rows a call retains — is deliberately not exposed here. This server already bounds its own output (a 15-row inline preview, and a cap on served CSV), and a row_limit on top of that would silently truncate a FORMAT CSV export, which is the opposite of what that route guarantees. Write LIMIT n in the query.

Refreshing a rebuilt graph

A --graph server serving a regular .kgl file re-reads it by itself. Every graph tool call first stats the served path, and when the file’s identity (length, mtime, device/inode) differs from the one the in-memory graph was loaded — or last saved — from, the server re-reads it through the normal open path before answering. There is nothing to configure and no manifest key, and what an agent can rely on is the strong property: a clean server never answers from, and never writes onto, a snapshot older than the file was at the time of the call.

reload_graph is still registered in --graph mode, read-only servers included. It forces the re-read instead of waiting for the next call, reports the new node/edge counts and this server’s load count (Load N on this server.), and is the refresh path for the cases the automatic one declines. A failed re-read keeps the current graph serving and returns the error.

What that costs, and where it stops:

  • Every save by another process costs each other server one full re-read on its next tool call — seconds on a ~100 MB graph, paid inside whichever tool call happens to be first, with concurrent calls waiting behind it. The re-read is single-flight and lazy: calls that all saw the change queue behind one load rather than starting several, and a producer that writes ten times between two queries costs one re-read, not ten.

  • The stat runs on the calling thread, so a .kgl on a hung network volume stalls tool calls in the freshness check itself. Serve graphs from local storage.

  • A failed re-read keeps the previously loaded graph serving and attaches a warning to tool results. It is retried only when the file’s identity changes again and at least five seconds have passed since the failure — so a producer republishing torn bytes cannot cost every call a doomed load, and a file that stays broken is never retried automatically at all. An explicit reload_graph always tries.

  • A file written by a newer kglite than this binary cannot be read at all, and the warning says to restart this server on a newer kglite rather than offering a retry that can never succeed.

  • A server holding unsaved changes never auto-reloads — the re-read would discard them silently. It warns on every response instead and leaves the choice to save_graph_as or reload_graph(discard_unsaved=true) (see The writer lease below).

  • Disk-graph directories carrying a CURRENT pointer are refreshed too. A disk publish is atomic in the same way a .kgl rename is: it stages a fresh generation and swings CURRENT to it, never rewriting the generation another server has mapped, and never deleting one. The pointer is the identity, so a peer’s publish is noticed on the next tool call exactly as a republished file is — at the cost of one open and read per call rather than a bare stat. A legacy flat directory (CSR files at the root, no CURRENT) is not refreshed: its files are rewritten in place, so there is no pointer to compare and reload_graph remains its refresh path.

  • extensions.graph_watch is retired. The key is still parsed — a non-boolean value still fails boot — but any boolean now only logs a retirement warning and arms nothing, because the refresh it used to opt into is unconditional. Remove it from the manifest.

Writable workbench

kglite-mcp-server --graph /data/work.kgl --writable
kglite-mcp-server --graph /data/new.kgl --storage memory --writable

A server is write-enabled when either --writable is passed on the command line or the manifest sets extensions.writable: true. They are one statement made two ways, and either alone enables mutation through cypher_query and registers save_graph plus the load_graph, create_graph, and save_graph_as lifecycle tools:

extensions:
  writable: true

builtins.save_graph: true is not a third spelling. On its own it registers save_graph and nothing else — it exists so a server can persist what it loaded, such as an ontology materialized from extensions.ontology at boot — and leaves cypher_query read-only. A mutation refused on such a server names both write-enabling spellings and says so.

One thing can outrank both spellings: a Rust binary that embeds this server as a library may pin it read-only (ServerExtensions::read_only()), which an operator cannot lift from either surface. That is deliberate — the embedder owns argv but not the manifest, and regenerates the graph from its own source of truth. Such a server logs one warning at boot naming the write opt-in it overrode, so --writable or extensions.writable: true doing nothing is visible in the log rather than a mystery. The stock kglite-mcp-server binary sets no pin; check the log if a wrapper binary refuses mutations you enabled.

A misspelled key is the one failure this adds, and it fails safe: the server comes up read-only and the first mutation is refused. Boot also warns about any extensions: key this server does not read, listing the ones it does — cypher_recipes, value_codecs, ontology, graph_watch, parallel, tools_allow, write_scope, csv_http_server, embedder, writable — so extensions.writeable: true shows up in the log instead of silently doing nothing. It is a warning rather than a boot error because a skill’s applies_when: {extension_enabled: …} predicate reads the same block and may legitimately name a key no reader here knows.

--storage memory|mapped|disk is required when the --graph target does not yet exist, and on an existing graph it converts: a memory-saved graph booted with --storage mapped comes up mapped. A disk graph is a directory rather than a file, so converting into or out of disk mode has no in-place form and is refused at boot naming enable_disk_mode(). Omit the flag to serve whatever mode the graph recorded. Keep read-only mode for untrusted agents and scope filesystem access with manifest source_root/source_roots.

The writer lease, and several servers on one file

A .kgl has one writer at a time, guarded by an advisory lock on a <name>.kgl.lock sidecar. A server takes that lease at its first unsaved change, not at boot:

  • A read-only server serving a .kgl that already exists never takes it. Any number of them can serve one file while a rebuilder republishes it in place.

  • A write-enabled server (--writable, or extensions.writable: true) boots lease-free as well. The first mutating cypher_query acquires the lease, and it is held until save_graph writes the changes back, save_graph_as moves them elsewhere, reload_graph(discard_unsaved=true) drops them, or the process exits. Outside that window the server is an ordinary reader.

  • A server with builtins.save_graph: true alone has a read-only cypher_query, so it never holds unsaved mutations and never opens a lease window for them. It still owns the file: it takes the lease for the moment a save_graph publishes — the boot-time ontology materialization that key exists to persist — and hands it straight back.

So several write-enabled servers — four MCP clients booted from one manifest, say — can serve the same graph and arbitrate per write rather than per process. The first to mutate holds the lease; a peer that writes while it is held waits about a quarter of a second and is then refused, by name:

cypher_query refused: /data/work.kgl is open for writing by "Claude Desktop"
(pid 4711, since 2026-09-01T09:12:04+02:00); only one process may write a graph
at a time. […] Nothing was changed here, and this graph is still readable —
keep querying it.

That name is --lease-label, else the KGLITE_LEASE_LABEL environment variable, else the name of the process that spawned this server — usually the MCP client itself, which is how four clients sharing one manifest still name themselves apart. A refused write changes nothing, the graph stays readable, and this server picks up what the holder wrote on its next call.

Writes that reach disk cannot silently overwrite each other either:

  • save_graph refuses if the file changed on disk since this server loaded or last saved it. There is no merge between the two versions: save_graph_as to another path keeps this server’s work, and reload_graph(discard_unsaved=true) drops it and serves the file as it is.

  • save_graph with nothing unsaved is a no-op: it answers Nothing to save: <path> is clean and carries no unpersisted configuration, so the file was not touched., takes no lease, and leaves the file’s identity alone — so peers serving the same graph are not made to pay a full re-read for a save that would have written the same graph back. Two things still get written. Unsaved mutations, obviously; and configuration the version counter cannot see, which today means a manifest ontology applied at boot (extensions.ontology, declared or materialized) — such a server’s first save persists it and its second is the no-op, and its response names what it wrote (wrote manifest ontology (N classes, M managed labels); no data changes) rather than a node count nothing moved. For a deliberate rewrite that neither explains, such as re-encoding the file with the running library version, pass force=trueonly on a write-enabled server. force re-encodes the file and moves its identity, so it is offered where mutations are (--writable / extensions.writable: true) and refused on a server that registers save_graph alone.

  • save_graph_as to the bound path is save_graph under another name, that lost-update check included. To a different path it also releases the source file’s lease — the graph is not going back there, and this is the call an agent reaches for to get out of the jam.

  • reload_graph refuses to discard unsaved changes silently, and load_graph / create_graph refuse outright while the server is dirty. All three name reload_graph(discard_unsaved=true): throwing work away has one spelling.

  • Every cypher_query result footer — reads and writes alike — carries file saved <T>, load N, and either clean or unsaved changes lease held since <T>, as do the <active_graph> header on graph_overview (file_saved="…" load="…" state="…") and the activation summary. A lease parked by a write that died mid-call is therefore visible on every query instead of only to whoever writes next.

  • load is server-local; file saved is the shared identity. load counts the graphs this server process has installed since boot, so it is how you tell a re-read from a skipped freshness check on one server — and two servers on the same path report different numbers for the same bytes, and a server’s own save does not move its own. file saved is the served path’s publish time taken off the filesystem, so it is the field every server on the path agrees on once refreshed, and the one to compare when you are asking whether two clients are serving the same graph. A server holding unsaved changes reports the moment it loaded rather than the file’s current one — correct by design: that is the identity its save_graph will be checked against. The field is omitted entirely for a graph with no file behind it (a workspace graph) and for a legacy flat directory, which has no publish moment.

The same applies to a disk-graph directory carrying a CURRENT pointer: it is a graph republished atomically, so it is served lease-free and locked only between a first unsaved change and the save_graph that publishes it. Several servers can therefore serve one directory and arbitrate per write. Reading one lock-free is safe because a publish never touches the generation a reader has mapped — it stages a new one and swings the pointer — and kglite deletes no generation, so nothing disappears under a live mapping. These generations/ directories are the disk mode’s own on-disk versions and are unrelated to the load counter in the footer: load counts one server’s installs and is not written anywhere, while a generation is a published artifact every process sees.

Two targets keep the lock from the open instead, because waiting is not safe for them: a path that does not exist yet (this open is creating it, and locking first is what stops two servers from both creating it), and a legacy flat directory — a pre-generations disk graph whose CSR files sit at the root with no CURRENT beside them, which a rebuild rewrites in place under this server’s live mappings. A created path joins the lazy lifecycle once its first save_graph has published it; a legacy flat directory keeps its lock for as long as the server serves it.

Budget for the directory’s growth before you enable save_graph on one: every disk save writes a complete new generation and the superseded ones are retained deliberately, so N saves leave N full copies. There is no retention policy — prune old generations/gen_* directories yourself once no reader is using them, as described under Durability in the durable-apps guide.

Operating notes:

  • Never delete <name>.kgl.lock from a build script or a cleanup job. Deleting it does not release a live lock and does nothing for a dead one — the operating system releases the lease when the holder exits, crash included. All the deletion removes is the <name>.kgl.lock-owner record that lets the next refusal name the holder. That record also says how the last holder left: a lease handed back cleanly appends a released=<timestamp> line to it, and a record with no such line was left by a holder that died still holding one. It is forensics, not liveness — whether a write waits is decided by the lock, so a released=-less record beside a file nothing holds means the last writer crashed, not that the graph is locked.

  • A peer that merely inspects the graph with kglite.open(path) rewrites it. open() is the writer’s entry point: it takes the lease, and its close() (or with-block exit) writes the whole graph back even when nothing was mutated. That rewrite costs every serving server one full re-read on its next call, and a server that was holding unsaved changes has its save_graph refused from then on. Inspect with kglite.load(path) or kglite.open_session(path), which take neither the lease nor the save-back binding.

  • An unlocked library save can replace this server’s checkpoint. Python handles opened with locking transfer their lease on save-as. Handles from kglite.load(), explicit lock=False, the raw Rust save_graph and C save entry points still rely on the caller to coordinate writers. A script that does kglite.load(path), mutates in memory and calls save(path) therefore publishes over a path this server is mid-write on. Nothing is lost from the file — it holds a complete graph, and this server’s own save_graph then refuses because the file changed on disk — but the agent’s unsaved work is not in it and has to be redone. Any caller that may save to a path must hold the lease across the whole read-modify-save interval: reach for kglite.open(path), not load() + save().

The source root --graph binds by default

In --graph mode a manifest that declares no source_root/source_roots does not leave the server without one: the parent directory of the .kgl file is auto-bound as the sole static source root, so the file-reading tools serve the files sitting next to the graph with no configuration. That is the default, not a fallback for a missing manifest — a manifest that configures Cypher tools and skills but says nothing about roots still gets it. Reads stay confined to the bound root, so the directory the graph lives in is exactly the blast radius: a .kgl at the top of a home directory or a shared volume binds all of it.

An explicit declaration wins outright — the auto-bind applies only when the manifest names no roots at all:

# serve the graph from /data but read files only from /srv/project
source_roots: [/srv/project]

To scope it, name the narrower directory; to move it, name a different one; to serve no files from a wide graph directory, keep the graph in a directory of its own, or drop the source tools from extensions.tools_allow (above), which is the closed-by-default surface. --source-root/--watch mode has no auto-bind question: the directory is the argument.

Pinning the write scope

cypher_query’s write_scope argument is set by the agent, so by itself it is role hygiene rather than access control. The operator’s counterpart is --write-scope (comma-separated) or extensions.write_scope:

kglite-mcp-server --graph /data/work.kgl --writable --write-scope Plan,Task
extensions:
  write_scope: [Plan, Task]

The pin is a ceiling, and it never falls open:

  • the agent omits write_scope → the pinned scope applies (not unrestricted);

  • the agent supplies one → the two are intersected, so it can narrow but never widen;

  • nothing left in scope → the write is refused, with a message naming the server’s scope so the agent can tell a policy refusal from a typo;

  • flag and manifest key set → those two are intersected as well, and the effective scope is logged at boot.

A malformed extensions.write_scope — anything but a list of strings — fails the boot rather than being dropped, on the same reasoning as extensions.tools_allow. An explicit [] is honoured literally: a write-enabled server that permits no writes.

The scope covers node writes (by the node’s stored type, so a pattern label cannot widen it) and relationship writes (at least one endpoint’s stored type in scope). Outside it, deliberately: relationship constraint DDL, db.cdc.*, and the graph-lifecycle tools, which replace or persist the whole graph rather than writing nodes in it — an agent that must not swap the served graph should not have load_graph/create_graph/save_graph_as in extensions.tools_allow.

Those tools are also the ones that end a lease window, so an allowlist that hides both save_graph and reload_graph from a server that can still mutate leaves it holding the writer lease from its first write until the process exits — locking every peer out of the graph (a .kgl or a generation directory alike) for the session. Hide the mutation route (cypher_query write scope, or read-only mode) rather than the way back out of one.

Code intelligence

The generic KGLite server serves and queries code graphs but does not build them. Use codingest-mcp for repository cloning, parsing, local watch mode, and multi-revision code-graph construction; it embeds this same graph-serving surface with the builder injected.

The complete manifest, skill, tool-gating, and client-registration reference is the MCP servers guide.