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.
Large tool results use discoverable byte budgets. They retain complete structured evidence for targeted expansion. See Bounded agent responses for:
the response controls;
collision-safe action composition;
the lifecycle;
the distinction between presentation truncation and query limits.
kglite-mcp-server --graph /data/graph.kgl
kglite-mcp-server --selftest --graph /data/graph.kgl
The default server 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;
CSV-over-localhost export.
A served .kgl can also carry its own skills and recipe queries. An opted-in
server merges them with the manifest’s. A binary that embeds this server can
register a third set of its own (ServerExtensions::with_skills /
with_recipes) that applies to every graph it serves. The manifest outranks
both. See the MCP servers guide and
Authoring MCP skills.
Point MCP clients at the absolute executable path. This avoids an older
installation that shadows the new one on PATH.
Pinning the tool surface¶
extensions.tools_allow names the tools a deployment exposes and hides
everything else.
Without it, a server exposes the union of everything that registered:
framework builtins;
the source tools the mode binds;
KGLite’s graph tools;
manifest Cypher tools;
routes that appear from a dependency or a mode change without the manifest ever naming them.
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 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. A route arriving later cannot widen the surface without an edit to the list. Such a route can come from a new dependency, an exported credential, or a mode change. Hidden tools are unlisted and rejected when called by name.
Details worth knowing before writing an allowlist:
Names are the final, agent-visible ones. If a
tools:override renamespingtodomain_ping, the allowlist must saydomain_ping.Naming a tool that is not registered in this boot is harmless. You can list conditional routes safely, so one manifest works across environments. Conditional routes include:
github_apiwithout thebuiltins.githubopt-in or without a token;load_graphon a read-only server;exploreon a non-code graph.
It only removes. Listing a tool that another rule hid does not bring it back. Examples are
repo_managementin a local workspace and ahidden: trueoverride.The list is the whole surface, not an addition to a default set. If you omit
ping, the server has noping. An explicittools_allow: []is taken literally and leaves no tools at all.A manifest that configures
extensions.cypher_recipesmust listlist_recipe_queriesandrun_recipe_query. Omitting them is refused at boot rather than serving a catalog no agent can reach.The
skillloader is exempt. With skills on, the framework registersskill(name)after the allowlist has been applied, so it is served whether or not the list names it. That is deliberate. It is a read-only fetch of methodology the deployment already chose to serve. Hiding it would leave every lazy skill’sskill("<name>")pointer aimed at a tool the agent cannot call. Turn skills off if you do not want it.A malformed value fails boot instead of being ignored. A value is malformed if it is not a list, or if an element is not a string. An allowlist that silently fails open is worse than none.
Optional parameters (default:)¶
A parameter an agent may omit declares a JSON-Schema default:. The server
binds it before the query runs. This applies to manifest tools[].cypher
entries and to extensions.cypher_recipes queries alike:
tools:
- name: search_docs
parameters:
type: object
properties:
query: {type: string}
limit: {type: integer, default: 5}
required: [query]
cypher: MATCH (d:Doc) WHERE d.title CONTAINS $query RETURN d LIMIT $limit
Only an absent argument is filled. An explicit value wins, and an explicit
null stays null.
Without a default, an omitted parameter reaches the engine unbound. The call
then fails with Missing parameter: $limit. For that reason coalesce($limit, 5) in the query does not help. A parameter with no default is effectively
required, however the schema describes it.
Defaults in a recipe’s closed schema¶
In a recipe’s closed schema, the default and required halves must agree:
A property with a
defaultis the one thingrequiredmay leave out. Listing it as required anyway is refused at boot.When every property has a default,
requireditself may be left out. Absent means none, as in JSON Schema.A default must satisfy its own property: its type,
enum, andminimum/maximum. Otherwise the catalogue fails to compile. A wrong default is therefore an operator’s boot failure rather than an agent’s call-time error.
Manifest tools: schemas are published as written and never compiled, so their
defaults are bound as-is.
Query deadlines¶
Every query this server runs has a 180,000 ms (three-minute) deadline. It is the same default the Python API applies, shared as one constant in the engine.
The deadline is a liveness property, not a preference. An agent has no cancel channel once a tool call is in flight. A runaway read holds the active graph’s read lock, which stalls the single-flight rebuild gate that 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 the
deadline for one call. timeout_ms: 0 runs without a deadline. It is the
escape hatch for the long analytical query the default exists to bound; use it
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 is deliberately not exposed here. In Python it caps the rows a
call retains. This server already bounds its own output, with a 15-row inline
preview and a cap on served CSV. 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. There
is nothing to configure and no manifest key.
Every graph tool call first stats the served path. The server re-reads the
file through the normal open path before answering when the file’s identity
differs from the one the in-memory graph was loaded or last saved from. The
identity is the length, mtime, and device/inode. An agent can rely on 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, and it
reports the new node/edge counts and this server’s load count (Load N on this server.). It 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 the automatic refresh costs, and where it stops:
Every save by another process costs each other server one full re-read on its next tool call. That is 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. A producer that writes ten times between two queries costs one re-read, not ten.
The
statruns on the calling thread. A.kglon 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 both conditions hold: the file’s identity changes again and at least five seconds have passed since the failure. A producer republishing torn bytes therefore cannot cost every call a doomed load, and a file that stays broken is never retried automatically at all. An explicit
reload_graphalways tries.A file written by a newer kglite than this binary cannot be read at all. 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, because the re-read would discard them silently. It warns on every response instead and leaves the choice to
save_graph_asorreload_graph(discard_unsaved=true). See The writer lease below.Disk-graph directories carrying a
CURRENTpointer are refreshed too. A disk publish is atomic in the same way a.kglrename is. It stages a fresh generation and swingsCURRENTto it. It never rewrites the generation another server has mapped, and never deletes one. The pointer is the identity, so a peer’s publish is noticed on the next tool call exactly as a republished file is. The cost is one open and read per call rather than a barestat.A legacy flat directory is not refreshed. It has CSR files at the root and no
CURRENT. Its files are rewritten in place, so there is no pointer to compare, andreload_graphremains its refresh path.The automatic re-read refreshes skills. When it installs new bytes, it re-resolves the skill layer from the graph’s
KgliteSkillrecords. It tells the client its tool list changed, asreload_graphdoes. Graph-carried recipe queries are fixed for the session either way.extensions.graph_watchis retired. The key is still parsed, and a non-boolean value still fails boot. 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.
Serving a markdown vault (--vault)¶
--vault DIR serves a directory of frontmatter-markdown notes as a knowledge
graph built from the files themselves. The format is the one
VAULT.md specifies.
kglite-mcp-server --vault /notes/handbook
There is no .kgl to produce or keep in step. The notes are canonical, the
graph is a derived view, and the server rebuilds it as the files change. A
manifest is optional; the common invocation is the one above.
Boot does the following, in order:
Opens the graph for
DIR. It loads the vault’s cached graph and rebuilds only if the directory has moved since. A build takes well under a second for a 7 000-note vault.Binds
DIRas the source root, soread_source/grep/list_sourceserve the notes as files too.Registers
rebuild_graph.Installs the vault’s own skills and recipe queries.
Behaviours to know:
.kglite/vault.yamlconfigures the build: profile overrides, declared property types, indexes, text indexes, an ontology, andembed:targets. It is re-read on every rebuild, so it is the one piece of state a rebuild re-applies. A file that will not parse fails the build. The previously built graph keeps serving, and the error names the file. It never degrades to an empty graph..kglite/skills/*.mdand.kglite/recipes/*.mdbecome the graph’s own agent guidance, re-read on every rebuild. Editing one is the whole update procedure. The served skill set is re-resolved after the rebuild that picked it up. A file that fails validation is skipped with a warning naming it, and its siblings still load.Rebuilds are lazy. The watcher tags the graph dirty (debounced 500 ms upstream), and the rebuild happens on the next tool call. Fifty saves cost one rebuild. A path inside a hidden directory other than
.kglite/is ignored, which keeps.obsidian/and.git/churn from rebuilding anything.The graph is cached at
.kglite/graph.kgl. Boot writes it and the next boot loads it, so a restart over an untouched vault answers immediately instead of re-reading every note. Each rebuild refreshes it. The file is excluded from the vault’s own fingerprint and from the watcher, so writing it never looks like an edit. It is an ordinary.kgl, so a vault can be shipped with its graph: build it once, commit it, and the first boot anywhere costs astatpass.--vault-cache PATHkeeps the cache elsewhere, for a read-only vault or a vault whose git history should not carry it.--vault-cache noneswitches it off.A cache never fails the boot. One that cannot be read is rebuilt. One that cannot be written is skipped with a warning on stderr. The boot log always says which of “vault built from its notes” and “vault served from its cached graph” happened.
rebuild_graphforces a rebuild now and returns the build report: notes scanned, nodes by label, edges by type, and the errors and warningskglite okf checkwould print. It is the vault counterpart ofreload_graph, which is not registered here, because a producer-backed graph has no served file to re-read.Vectors are carried, not recomputed. With
extensions.embedder(andtrust.allow_embedder: true) bound, eachembed:target declared invault.yamlis embedded at boot and after each rebuild. Only notes whose text actually changed are re-embedded. A note that keeps its label and id keeps its vector across a rebuild. A note moved to another folder is relabelled, so it is re-embedded. Without a bound embedder the vault still serves, and a declaredembed:target logs a warning at boot rather than failing.--vaultand an injected producer are mutually exclusive. A binary that embeds this server and injectsWorkspaceGraphHooksis refused at boot. The message names--watchas the mode for its own producer, because only one producer can own the graph.
Serving images (fetch_images)¶
fetch_images is the only route that returns the bytes of Image nodes, and only for the
paths an agent names. Every  reference in a note becomes an Image
node whose id is its vault-relative path.
{"items": ["img/faults.png", "img/section-map.png"], "max_bytes": 1048576}
The reply is one image content block per delivered file, plus one text block. The text block lists each item as delivered (path, MIME, byte count) or refused (path, reason). A call where some items are refused still succeeds. A call where every item is refused is an error carrying the same list.
The sandbox is the source tools’. Paths resolve through the same
source_rootbindingread_sourceuses, so nothing outside the served directory is reachable. Absolute paths,~,file:URLs and..segments are refused before resolution. The message names the contract rather than reporting a miss.Registered everywhere, enabled where a root exists.
--graphauto-binds the graph’s parent directory (see below), so an ordinary--graphserver serves images sitting beside the.kglwith no configuration. A server that binds no root at all carries the route disabled and logs the reason at boot (“no source root: images are served from the vault root orsource_root”). That coverskglite-mcp-serverwith no mode flag, and a manifest whose declaredsource_rootsdo not resolve. An operator sees why, and a manifest that overrides the tool still resolves.png, jpeg, gif and webp only. Every other type, SVG and PDF included, is refused with its MIME named. Those files remain queryable as
Attachmentnodes; only their contents are unavailable. Convert diagrams to PNG when building the vault.Caps, never resizing. Defaults are 4 images per call, 4 MiB per image and 12 MiB per call. An over-cap item is refused with its byte count named. The call’s own
max_bytescan only lower the per-image ceiling. Override the defaults in the manifest:
extensions:
fetch_images:
max_items: 2
max_bytes_per_image: 1048576
max_total_bytes: 2097152
All three keys are optional and must be positive integers. Anything else fails the boot rather than falling back to a cap you did not choose.
One stderr line per delivered image (path and byte count), because this route moves more bytes per call than any other.
Writable workbench¶
A server is write-enabled when either --writable is passed on the command
line or the manifest sets extensions.writable: true.
kglite-mcp-server --graph /data/work.kgl --writable
kglite-mcp-server --graph /data/new.kgl --storage memory --writable
The two are one statement made two ways. Either alone enables mutation through
cypher_query. It also registers save_graph plus the load_graph,
create_graph, and save_graph_as lifecycle tools:
extensions:
writable: true
What outranks or fails to enable writes¶
builtins.save_graph: true is not a third spelling. On its own it
registers save_graph and nothing else, and it leaves cypher_query
read-only. It exists so a server can persist what it loaded, such as an
ontology materialized from extensions.ontology at boot. 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()), and an
operator cannot lift that 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.
Misspelled keys¶
A misspelled key is the one failure the manifest spelling (extensions.writable)
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, and the
warning lists 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. That predicate may legitimately name a key no reader here knows.
Storage mode¶
--storage memory|mapped|disk is required when the --graph target does not
yet exist. 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. It 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. Scope filesystem access with
manifest source_root/source_roots.
Embedding placement¶
--embeddings auto|mapped|memory (or extensions.embeddings.storage) decides
where the embedding stores of a v9 .kgl live. A graph saved with a store of
4 MiB or more is v9, and its vectors sit uncompressed in the file.
auto(default) reads a store of 64 MB or more in place from a regular local file on Unix.mappedreads every store in place;memorycopies every store to the heap.A mapped store costs no resident memory until a search touches it.
psand Linux cgroups still count the pages a search has touched; macOSphys_footprintdoes not.The first write to a store copies it to the heap.
Write a new file and rename it over the served one. Overwriting it in place (
cp,rsync --inplace) truncates a live mapping, and the next search faults.v9 files (a default save of a graph with an embedding store of 4 MiB or more, a property column buffer of 256 KB or more, 65,536 nodes or 262,144 relationships) are refused by kglite releases before the one that introduced them. Save with
layout="compact"for a file an older server must read.
Column placement¶
--columns auto|mapped|memory (or extensions.columns.storage) decides where
the property columns of a v9 .kgl live. A default save writes every column with a buffer of
256 KB or more uncompressed into the file.
auto(default) reads such a column in place from a regular local file on Unix; the server writes no spill file for it and a second process serving the same file shares the pages.mappedreads every column in place;memorydecodes every column, spilling those of 256 KB or more to a temp file as v8 files do.An integer column that is four times smaller as deltas (a monotonic id) stays compressed in the file and still spills.
The first write to a column copies it to the heap; other columns stay mapped.
A corrupt string column fails the load.
verify_integritychecks the numeric ones.As for embeddings: write a new file and rename it over the served one.
Topology placement¶
--topology auto|mapped|memory (or extensions.topology.storage) decides whether a graph saved in mapped storage reads its topology and index sections in place.
auto(default) andmappedread the sections in place from a regular local file on Unix; the server holds no heap copy of the topology and a second process serving the same file shares the pages.memoryignores the sections, decodes the compressed topology and rebuilds the indexes.A v8 or older file and an in-memory graph ignore it.
Write a new file and rename it over the served one.
Write budget¶
--write-budget default|unlimited|SIZE (or extensions.write_budget.limit) bounds the unsaved writes a mapped graph holds in memory. SIZE is a count of bytes or a number with a K, M or G suffix, such as 2GiB.
The default is
max(512 MiB, half the file).At half the limit the write that crossed it answers with a
warning:line, once per upward crossing, and the server logs it on stderr.At the limit a write fails with
write_budget_exceededuntilsave_graphwrites the changes out; reads keep working.The estimate counts overlay entries, new column heap and the heap of embedding stores written since the load.
An in-memory graph and a graph with no mapped topology carry no budget.
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
.kglthat 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, orextensions.writable: true) boots lease-free as well. The first mutatingcypher_queryacquires the lease. It is held until one of these happens:save_graphwrites the changes back;save_graph_asmoves them elsewhere;reload_graph(discard_unsaved=true)drops them;the process exits.
Outside that window the server is an ordinary reader.
A server with
builtins.save_graph: truealone has a read-onlycypher_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 asave_graphpublishes, and hands it straight back. That publish is the boot-time ontology materialization the key exists to persist.
Several write-enabled servers can therefore serve the same graph and arbitrate per write rather than per process. Four MCP clients booted from one manifest are an example. 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.
The name comes from the first of these that is set:
--lease-label;the
KGLITE_LEASE_LABELenvironment variable;the name of the process that spawned this server, usually the MCP client itself. This is how four clients sharing one manifest still name themselves apart.
A refused write changes nothing and the graph stays readable. This server picks up what the holder wrote on its next call.
Writes that reach disk¶
Writes that reach disk cannot silently overwrite each other either:
save_graphrefuses if the file changed on disk since this server loaded or last saved it. There is no merge between the two versions.save_graph_asto another path keeps this server’s work.reload_graph(discard_unsaved=true)drops it and serves the file as it is.
save_graphwith nothing unsaved is a no-op. It answersNothing to save: <path> is clean and carries no unpersisted configuration, so the file was not touched.It 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.
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. 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, pass
force=true. An example is re-encoding the file with the running library version.forceworks only on a write-enabled server. It 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 registerssave_graphalone.save_graph_asto the bound path issave_graphunder 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_graphrefuses to discard unsaved changes silently, andload_graph/create_graphrefuse outright while the server is dirty. All three namereload_graph(discard_unsaved=true): throwing work away has one spelling.Every
cypher_queryresult footer carriesfile saved <T>,load N, and eithercleanorunsaved changes — lease held since <T>. This holds for reads and writes alike. The<active_graph>header ongraph_overview(file_saved="…" load="…" state="…") and the activation summary carry the same fields. A lease parked by a write that died mid-call is therefore visible on every query instead of only to whoever writes next.loadis server-local;file savedis the shared identity.loadcounts the graphs this server process has installed since boot. It is how you tell a re-read from a skipped freshness check on one server. 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 savedis the served path’s publish time taken off the filesystem. Every server on the path agrees on it once refreshed. Compare it when you ask 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. That is correct by design: it is the identity its
save_graphwill 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.
Disk-graph directories¶
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 generation and swings the pointer. The
writer deletes only generations older than the one before the generation it
just published, so a reader that re-reads CURRENT per call always finds its
generation. The retention setting is KGLITE_KEEP_GENERATIONS: default one
previous generation, all to keep every one.
These generations/ directories are the disk mode’s own on-disk versions. They
are unrelated to the load counter in the footer. load counts one server’s
installs and is not written anywhere. 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. A created path joins the lazy lifecycle once its first
save_graphhas published it.A legacy flat directory: a pre-generations disk graph whose CSR files sit at the root with no
CURRENTbeside them. A rebuild rewrites it in place under this server’s live mappings. It 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 publishes a new generation. The generation shares (by hard link) the
column file of every node type it did not change. A disk save keeps the current
generation plus one previous, and older ones are deleted. That is the
KGLITE_KEEP_GENERATIONS setting described under Durability in the
durable-apps guide. Set it to all if a
reader in another process can stay more than one generation behind.
Operating notes¶
Never delete
<name>.kgl.lockfrom 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-ownerrecord 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. A record with no such line was left by a holder that died still holding one.It is forensics, not liveness. The lock decides whether a write waits. 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 itsclose()(orwith-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. A server that was holding unsaved changes has itssave_graphrefused from then on. Inspect withkglite.load(path)orkglite.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. These still rely on the caller to coordinate writers:
handles from
kglite.load();explicit
lock=False;the raw Rust
save_graph;the C save entry points.
A script that does
kglite.load(path), mutates in memory and callssave(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 ownsave_graphthen 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 forkglite.open(path), notload()+save().
The source root --graph binds by default¶
In --graph mode, a manifest that declares no source_root/source_roots
still gets a source root. 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. The directory the graph lives in is
therefore 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/--vault mode has no auto-bind question: the
directory is the argument.
Valid time: the default instant¶
On a graph that declares validity intervals, a cypher_query or recipe call
with no valid_at and no FOR VALID_TIME prefix reads as of today (UTC).
The temporal: line says so (default).
An agent that needs history sends
valid_at: "all"or starts the query withFOR VALID_TIME ALL.A recipe that means history starts with the prefix. The prefix does nothing on a graph with no declaration.
The operator moves the default with
--valid-time-default {today|all|YYYY-MM-DD}orextensions.valid_time.default. The flag wins.With neither set, the served graph’s own stored default (saved in its
.kgl) applies, else today.graph_overviewshows a<valid-time-default>line when the default is not today.
See Valid time.
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 fails the boot rather than being dropped.
Malformed means anything but a list of strings. The reasoning is the same as
for 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;
relationship writes, where at least one endpoint’s stored type is in scope.
The scope deliberately does not cover:
relationship constraint DDL;
db.cdc.*;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_asinextensions.tools_allow.
Those lifecycle tools are also the ones that end a lease window. Suppose an
allowlist hides both save_graph and reload_graph from a server that can
still mutate. That server holds the writer lease from its first write until the
process exits. It locks 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.