IPC & MCP Reference
liminis-context-graph serves the same graph over two transport surfaces, both routed
through the same core dispatch in
crates/core/src/handlers.rs —
no graph logic is duplicated between them:
- JSON-RPC 2.0 over a Unix domain socket (default). Newline-delimited requests/responses over
.lcg/service.sock. - Model Context Protocol over stdin/stdout (
--mcp-stdio). Any MCP client — Claude Code, Claude Desktop, other agents — can query and mutate the graph directly.
IPC methods (44)
The socket dispatch handles 44 methods: 43 knowledge_* methods plus health_check.
health_check is the one method not prefixed knowledge_*, and it is the reason the IPC
surface (44) and the MCP tool registry (43, below) differ by exactly one — health_check is
not exposed as an MCP tool.
| Category | Methods |
|---|---|
| Health | health_check |
| Status | knowledge_status, knowledge_rebuild_status |
| Ingestion | knowledge_process_chunk, knowledge_add_episode |
| Direct assertion | knowledge_assert_entity, knowledge_assert_relationship |
| Search | knowledge_find_entities, knowledge_find_relationships, knowledge_search_passages, knowledge_query_cypher |
| Graph reads | knowledge_get_episodes, knowledge_get_nodes_by_group, knowledge_get_edges_by_group, knowledge_get_edges_by_uuids, knowledge_list_entities, knowledge_list_relationships, knowledge_get_entity_neighbors, knowledge_get_entities_by_source |
| Deletion | knowledge_delete_episode, knowledge_delete_by_source, knowledge_delete_chunk_episode, knowledge_delete_by_group, knowledge_clear_all |
| Curation | knowledge_merge_entities, knowledge_validate_corrections, knowledge_apply_corrections, knowledge_reprocess_entity_types |
| Relation typing | knowledge_canonicalize_relations, knowledge_backfill_relation_types (deprecated), knowledge_reprocess_relation_types |
| Semantic search maintenance | knowledge_backfill_summary_embeddings |
| Cross-group pointers | knowledge_add_cross_group_edge, knowledge_rebind_pointers |
| WAL administration | knowledge_dump_wal, knowledge_prepare_checkpoint, knowledge_wal_mark_create, knowledge_wal_mark_list, knowledge_wal_mark_delete, knowledge_rebuild_from_wal, knowledge_build_indices |
| Recovery / lifecycle | knowledge_recover, knowledge_recover_full, knowledge_close |
For request/response shapes and parameter details, the dispatch match arms in
handlers.rs
and their handler functions are the source of truth — this page is the method index, not a
copy of each handler’s parameter parsing.
knowledge_status’s WAL fields — including wal.hydration_status (issue #456), which
distinguishes a genuinely empty group from one whose WAL holds unapplied content — are documented
field-by-field in Operations: knowledge_status health fields
rather than here.
Every long-running method above (the six WAL/recovery/reclassification operations most likely
to run for a while) accepts a _progress_token and streams {"type":"progress",...} frames
before the terminal result — see Progress notifications below.
Readiness
A successful connection to .lcg/service.sock is not evidence the service is ready. The
socket is bound before the database opens — deliberately, so health_check and recovery IPC stay
reachable during degraded-mode recovery (ADR-0009)
— and issue #378’s WAL-root migration also runs in that same pre-open window, after the bind.
(Legacy .graphiti/→.lcg/ workspace migration runs earlier still, before the socket is even
bound.) The process’s own accept loop only starts once startup work has fully resolved, so a
request sent immediately after connect() queues in the kernel rather than racing the migration
with stale state — the real risk is a client that treats connect() succeeding as readiness by
itself and acts on that assumption (e.g. inspecting on-disk state) without waiting for a
health_check round-trip.
The correct readiness signal is a health_check round-trip reporting "healthy":
handle_health_check only returns healthy once Db::open() has succeeded, which is after
migration has completed. Poll health_check until it reports healthy (or knowledge_status
until connected and queryable are both true and initializing is false —
knowledge_status has no healthy field of its own) before sending real work; see
Operations: Self-healing and degraded mode
for the full rationale. A degraded response after startup has otherwise settled is a legitimate
outcome (e.g. unrecovered corruption) — not something to retry indefinitely.
MCP-over-stdio transport
liminis-context-graph --mcp-stdio starts a native Model Context Protocol
server over stdin/stdout, using the official Rust SDK (rmcp). Any MCP client (Claude Code,
Claude Desktop, other agents) can query and mutate the knowledge graph directly — no Electron
app, no Node, no custom JSON-RPC client required. This is an additional external-facing
surface; the Unix-socket JSON-RPC protocol above is unchanged.
Every MCP tool is derived from the knowledge_* dispatch methods above — tool names match the
IPC method names verbatim, and each tools/call is translated into an IpcRequest and routed
straight through the same core dispatch the socket service uses. Tool descriptions and JSON
schemas are maintained in the
ToolSpec registry in crates/service/src/mcp/tools.rs —
that file is the canonical source for per-tool descriptions; they are not duplicated here.
Flags
| Flag | Description |
|---|---|
--mcp-stdio | Starts the MCP server over stdin/stdout instead of binding the Unix socket. |
--scope=<list> | Comma-separated list of scopes to advertise in tools/list (default all). See Scopes below. |
--connect <path> | Attached mode: forward every tools/call as JSON-RPC over the given Unix socket to an already-running service, instead of opening the database directly. |
--allow-remote-close | Attached mode only: advertise and allow knowledge_close, forwarding the shutdown to the remote service. No effect in standalone mode (no --connect). |
DB-access modes
- Standalone (default, no
--connect): the MCP process opens the.lcgdatabase directly, reusing the same startup and self-recovery path as the socket service (ADR 0009). Zero-dependency — works with no other process running. - Attached (
--connect <socket-path>): the MCP process never opens the database; it forwards each call over the given socket to a service that already has it open. Use this to add MCP access to a workspace where another socket-service instance is already running, without contending for lbug’s single-writer lock.- Idle timeout.
LCG_ATTACHED_CALL_TIMEOUT_MS(default 30s) is a per-read-line idle timeout, not a whole-call timeout: it resets on every line read off the socket, including{"type":"progress"}lines. A call that keeps emitting progress is never bounded by it, no matter how long the call runs in total — only genuine silence (no output at all for the full timeout window) trips it. If the remote stops responding mid-call (e.g. it crashes), the attached client fails that call with a clean timeout error rather than blocking forever. - Reconnect and retry. If the connection to the remote breaks, the client transparently
re-dials the same socket path rather than staying wedged. If the break is detected while
writing the outgoing request — treated as safe to retry, since the write failing is the
client’s best available signal that the request didn’t get through — the client
automatically retries that request exactly once over the freshly-dialed connection. If the
break is detected only after the request was fully written — while waiting for or reading
the response — the call is not retried automatically, since the remote’s execution
status is unknown and blind retry could double-apply a non-idempotent write (e.g.
knowledge_add_episode); that call fails with a clear “connection lost mid-call” error, but the connection is marked dead so the next call reconnects fresh. If a reconnect attempt itself fails (no listener at that path), the call fails with a clear, descriptive error — never a hang — and a later call will try reconnecting again. See ADR-0040 for the full rationale.
- Idle timeout.
Scopes
Scopes are additive and composable (e.g. --scope=read,admin). tools/list advertises the
union of all active scopes.
| Scope | Methods |
|---|---|
read | knowledge_status, knowledge_find_entities, knowledge_find_relationships, knowledge_get_episodes, knowledge_get_nodes_by_group, knowledge_get_edges_by_group, knowledge_get_edges_by_uuids, knowledge_search_passages, knowledge_list_entities, knowledge_list_relationships, knowledge_get_entity_neighbors, knowledge_get_entities_by_source, knowledge_rebuild_status, knowledge_validate_corrections |
write | knowledge_process_chunk, knowledge_add_episode, knowledge_delete_episode, knowledge_delete_by_source, knowledge_delete_chunk_episode, knowledge_clear_all, knowledge_apply_corrections, knowledge_merge_entities, knowledge_reprocess_entity_types, knowledge_canonicalize_relations, knowledge_backfill_relation_types, knowledge_reprocess_relation_types, knowledge_add_cross_group_edge, knowledge_assert_entity, knowledge_assert_relationship |
cypher | knowledge_query_cypher |
admin | knowledge_dump_wal, knowledge_prepare_checkpoint, knowledge_wal_mark_create, knowledge_wal_mark_list, knowledge_wal_mark_delete, knowledge_rebuild_from_wal, knowledge_recover, knowledge_recover_full, knowledge_close, knowledge_build_indices, knowledge_rebind_pointers, knowledge_delete_by_group, knowledge_backfill_summary_embeddings |
all | every scope above (default) |
cypher is a power scope, not bundled into anything else. knowledge_query_cypher executes
raw Cypher with no param interpolation or value coercion — despite being a “query” method, it can
perform arbitrary mutations, and it bypasses the WAL-ordering and embedding invariants that the
structured write tools maintain. It is never implicitly included in read, write, or admin;
operators must opt in explicitly (or via all).
knowledge_close in attached mode is a footgun without --allow-remote-close. In
standalone mode, knowledge_close is always advertised under admin scope and shuts down
only this MCP process’s own DB connection. In attached mode, calling it would shut down the
running remote service. Without --allow-remote-close, knowledge_close is omitted from
tools/list entirely in attached mode (not merely rejected when called). Pass
--allow-remote-close only when you specifically intend this MCP connection to be able to stop
the remote service.
Recovery and export live under admin. knowledge_rebuild_from_wal (rebuild one group’s data
from its own WAL directory — group_id, default "liminis"), knowledge_dump_wal
(snapshot/export the graph into a fresh compacted WAL directory), knowledge_wal_mark_create /
_list / _delete (name a retained WAL position within one group’s stream, without a full
snapshot — each also takes group_id, default "liminis", and _list reports only that one
group’s marks, never an aggregate across groups), and knowledge_recover / knowledge_recover_full
are all admin-scope tools — an attached client only sees them when launched with --scope=admin
(or all). If a mutation goes wrong, this is the recovery path. See Operations
for the recovery model in full. Note the WAL replays forward-only, so take periodic
knowledge_dump_wal snapshots, or a lighter-weight knowledge_wal_mark_create named position, if
you want restore points before large or destructive operations — a mark does not survive
knowledge_dump_wal, since dump_wal renumbers sequence numbers and a copied mark’s seq would be
meaningless against the new numbering.
knowledge_rebuild_from_wal refuses to run against a non-empty group, unless you ask it not
to. Since issue #378, one instance holds an independent WAL directory and applied position per
group_id; knowledge_rebuild_from_wal {group_id, ...} targets exactly one of them and never
disturbs another group’s data or position. A from_seq: 0 (default) full rebuild against a group
that already contains data fails fast with an explicit error rather than silently emitting a
duplicate-primary-key failure for every existing Entity/Episodic/RelatesToNode_ row in that
group — the native write path uses CREATE, not MERGE, for those labels. Pass force_clear: true to have the call clear that group’s data automatically before replaying (the same
group-scoped purge knowledge_delete_by_group uses — this does not delete or reopen the
database file, unlike the pre-378 whole-database force_clear behavior), or clear it yourself
first with knowledge_delete_by_group {group_ids: [group_id]}. dry_run: true always fails fast
on a non-empty group regardless of force_clear, since a dry run must never mutate the database —
this lets a preview surface the problem before you commit to a real rebuild. None of this applies
to an incremental from_seq > 0 resume, which intentionally targets a group that already has
state.
to_seq bounds replay from the other end: from_seq <= seq <= to_seq. Pass an inclusive
upper bound to exclude a mutation (and everything after it) from a rebuild — e.g. a WAL-recorded
mutation that corrupted the graph. Omit to_seq for today’s unbounded behavior (replay to the
end of the WAL); it must not be less than from_seq, or the call is rejected before any WAL line
is read or the database is touched. A bounded rebuild is not durable: WAL entries past
to_seq stay on disk, unapplied, not truncated or archived — a later unbounded rebuild, or a
from_seq resume that covers the excluded range, reapplies them, including a previously-excluded
bad mutation. to_seq bounds an endpoint; it does not add reverse/undo semantics to the
forward-only replay noted above.
group_ids semantics: omitted vs. empty
Every read tool that accepts group_ids treats an omitted or null group_ids uniformly as
“all groups” (issue #413) — this holds across all 9 read tools: knowledge_find_entities,
knowledge_find_relationships, knowledge_search_passages, knowledge_list_entities,
knowledge_list_relationships, knowledge_get_entity_neighbors, knowledge_get_entities_by_source,
knowledge_get_nodes_by_group, knowledge_get_edges_by_group.
An explicit group_ids: [] does not mean the same thing on every tool, and this split is
deliberate, not an inconsistency to be fixed here:
- On
knowledge_find_entities,knowledge_find_relationships,knowledge_get_nodes_by_group, andknowledge_get_edges_by_group, an explicitgroup_ids: []is preserved as “exactly these groups” — i.e. zero rows, a filter matching nothing. - On the other five read tools (
knowledge_search_passages,knowledge_list_entities,knowledge_list_relationships,knowledge_get_entity_neighbors,knowledge_get_entities_by_source), an explicitgroup_ids: []collapses to the same behavior as omitting it — all groups.
If you need “zero rows” as a filter result, use one of the four tools in the first list, or check
the tool’s own ToolSpec description in
crates/service/src/mcp/tools.rs
before relying on [] to mean “nothing” — on the other five it doesn’t.
This section is the single, central statement of that contract; individual tool entries on this page don’t restate it.
Deletion (delete_chunk_episode, delete_by_source)
Breaking change in 0.13.2 (issue #406): group_ids is required and non-empty on both
knowledge_delete_chunk_episode and knowledge_delete_by_source. A call that previously
omitted group_ids deleted matching episodes across every group in the workspace; that was
cross-group-unsafe, so as of 0.13.2 an omitted, null, or empty group_ids is rejected outright
with an error, before any delete runs. There is no default to fall back to — a caller
upgrading from a pre-0.13.2 client that relied on omission must start passing its own group(s)
explicitly:
{"chunk_id": "notes-0001", "group_ids": ["liminis"]}{"source_file": "notes.md", "group_ids": ["liminis"]}This is a different contract from the read-tool one above — omitting group_ids here never means
“all groups”; it means “reject the call.” A group_ids naming a group with no matching rows still
succeeds, returning deleted_count: 0.
Direct assertion (assert_entity, assert_relationship)
knowledge_assert_entity and knowledge_assert_relationship (issue #379) are a direct write
path: the caller already knows a fact and records it as a single entity or edge, without a prose
round-trip through knowledge_process_chunk’s LLM-driven extraction. Use process_chunk when
you have unstructured text and want the graph populated by extraction; use the assert tools when
you already know exactly which entity or edge you want to write.
knowledge_assert_entity accepts name (required), entity_uuid (optional), labels,
summary, attributes, and group_id (default liminis).
- Upsert identity is
(name, group_id), unlessentity_uuidis supplied.entity_uuid, when given, is a strict, group-scoped lookup — the call fails if no entity with that UUID exists ingroup_id; there is no create-under-this-UUID fallback (ADR-0379 Decision 3). A caller cannot mint an entity at a UUID of its own choosing. - On an update, omitting
summaryorattributesclears the previously stored value rather than leaving it untouched — both fields are always overwritten with whatever the call supplies:summarydefaults to""if omitted,attributesdefaults to"{}"(an empty JSON object, not an empty string) if omitted or non-object, matching howlabels/nameare handled. - Only
nameis embedded for semantic search (name_embedding).summaryis stored and is full-text searchable (it’s part ofEntity’s[name, summary]FTS index), but is not semantically searchable — aknowledge_find_entitiesvector query will not match onsummarycontent until issue #470 lands.attributesis not indexed at all — full-text or semantic — and is retrievable only via direct UUID lookup orknowledge_query_cypher. - An update never re-embeds
name_embedding— only a create does (ADR-0379 Decision 6, forced by a lbug constraint: the embedded column sits under an HNSW index once indexes are built, and lbug rejects a plainSETon an indexed column). Re-asserting an existing entity with a changednameupdates the storedname, butname_embeddingkeeps reflecting the old name until the entity is deleted and recreated. There is currently no supported way to refresh it in place.
knowledge_assert_relationship accepts source_name, target_name, predicate (all
required), fact (optional — auto-derived as "{source_name} {predicate} {target_name}" if
omitted), attributes, relation_type, valid_at, and group_id (default liminis).
- Upsert identity is
(source_node_uuid, predicate, target_node_uuid, group_id), resolved viafind_active_relates_to_uuid(an invalidated edge is skipped, so re-asserting after an invalidation creates a fresh edge rather than resurrecting the old one). - Endpoint resolution is strictly scoped to the call’s own
group_id— it never falls back to a cross-group search. Ifsource_nameortarget_namedoesn’t resolve to an entity already in that group, the call fails with an error namingknowledge_add_cross_group_edgeas the tool to use for connecting entities across groups. factis the field embedded for semantic search (fact_embedding) — notname/predicate, the opposite ofknowledge_assert_entity.factis also part ofRelatesToNode_’s[name, fact]FTS index, so it’s both full-text and semantically searchable;attributesis indexed neither way, same as on the entity side.- Same never-re-embed-on-update caveat as
knowledge_assert_entity(ADR-0379 Decision 6): a re-assert that changesfactupdates the storedfacttext but leavesfact_embeddingreflecting the prior text until the edge is deleted and recreated.
See ADR-0379 for the full rationale behind these upsert and embedding decisions.
Relation typing (canonicalize_relations, backfill_relation_types, reprocess_relation_types)
Three tools populate an edge’s relation_type, with different tradeoffs:
knowledge_canonicalize_relations maps each edge’s existing raw predicate onto your
ontology’s declared relation_types. Its behavior has four caveats worth knowing before you
rely on it:
group_idis required (issue #447) — candidate selection and the resulting WAL mutations are both restricted to that one group; no other group’s edges or WAL stream are ever touched. An omitted,null, or emptygroup_idis rejected before any candidate selection or write, rather than falling back to a database-wide rewrite or the default group — there is no supported way to canonicalize every group in one call.- The primary pass is lexical, over the predicate — not the
fact. It matches the edge’s predicate / currentrelation_typeagainst ontology type names, aliases, and keywords. It does not read the edge’sfactsentence, so an edge whoserelation_typewas cleared cannot be re-mapped from its fact by this pass. embedding_thresholdtunes only the fallback promoter (default0.7). The fallback embeds each residual edge’sfactagainst the ontology types’ descriptions and force-assigns the single nearest type at or above the threshold. Lowering it types more edges, but by nearest-neighbor force-fit with no abstention — an idiosyncratic fact (e.g. “X is affiliated with Y”) can land on a wrong type (e.g.HOLDS).- Re-runs are only partly idempotent, and clearing
relation_typecan’t be undone by canonicalize. A re-run skips an edge only when it’s already at its target — aMappededge already equal to the canonical type, or a residual edge alreadyUNCLASSIFIED; an edge whose classification changes is overwritten (including a previously-assigned type), while arrow-named “noise” edges keep any existing predicate. Critically, canonicalize’s only input is the edge’s existing predicate /relation_type— if you null that field to “start clean,” canonicalize has nothing to map from and cannot rebuild it. Snapshot withknowledge_dump_walbefore such an operation.
knowledge_backfill_relation_types (DEPRECATED) does not classify at all — it mints
uppercased fact-prefix pseudo-types (e.g. THE_SPECIFICATION_DOCUMENT_DEFINES) for edges with no
relation_type, rather than matching against the ontology. Avoid it for building a typed
taxonomy; prefer knowledge_reprocess_relation_types below, which supersedes it for that purpose.
Like knowledge_canonicalize_relations, group_id is required (issue #447): candidate
selection and WAL attribution are both restricted to that one group, and an omitted, null, or
empty group_id is rejected rather than running database-wide or against the default group.
knowledge_reprocess_relation_types is the relation-side twin of
knowledge_reprocess_entity_types: for each in-scope edge, it sends the edge’s fact and the
ontology’s declared relation types (name + description) to the configured extraction LLM and asks
it to pick exactly one type, or honestly abstain. This is the tool to reach for when you want
genuine fact-based classification instead of lexical matching or pseudo-typing:
- Always reads the
fact, never the predicate. Unlikecanonicalize_relations, classification is grounded in the edge’s natural-language fact sentence against the ontology’s declared menu — not lexical/alias/keyword matching on the existing predicate string. - A declared ontology relation-type menu is always required. Unlike
knowledge_reprocess_entity_types(whoseuntypedscope works with no ontology via open-ended classification), every scope value (untyped,off_ontology,all) fails with a structured{success: false, error: ...}if the ontology declares no relation types — there is no open-ended fallback (see ADR-0037). - Abstention is an honest, real write of
UNCLASSIFIED. If the LLM cannot map a fact to any declared type, the edge’srelation_typeis set to the literal stringUNCLASSIFIED— never a force-assigned nearest match. This differs fromknowledge_reprocess_entity_types, where an unclassifiable entity is simply left unchanged (see ADR-0037). scopecontrols candidates:"untyped"(default) —relation_typeNULL/empty, the same predicatebackfill_relation_typesuses;"off_ontology"— untyped edges plus edges whoserelation_typeisn’t a declared type (this naturally covers priorUNCLASSIFIEDsentinels andbackfill_relation_types’s fact-prefix pseudo-types with no special-casing);"all"— every edge in the group.- Idempotent. An edge whose computed verdict already matches its current
relation_type(including an edge already correctlyUNCLASSIFIED) is left unchanged — no write, no WAL entry. dry_run: truereturnswould_reclassify_count, aplanarray of per-edge{edge_id, fact, old_type, new_type}entries, and abreakdownobject counting edges per assignednew_type(including anUNCLASSIFIEDcount) — without mutating the graph.dry_run: false(apply) returnsreclassified_count,unchanged_count, and — since issue #332 — the samebreakdownobject as the dry-run path, so callers can see the per-type classification distribution (including how many candidates abstained toUNCLASSIFIED) without a separate dry-run call.planandwould_reclassify_countremain dry-run-only, since they describe a proposed mutation rather than one that already happened;breakdownis always present on a successful apply response, as{}when there were zero in-scope candidates.
Semantic search maintenance (backfill_summary_embeddings)
knowledge_backfill_summary_embeddings (issue #470) computes summary_embedding for every
entity in group_id that has a non-empty summary, so entities created before this capability
existed become semantically retrievable by summary paraphrase — not just by name-vector or
full-text match. knowledge_find_entities already fuses a summary-vector match into its hybrid
retrieval for entities embedded going forward (both knowledge_assert_entity and the extraction
path embed summary on creation); this tool is what makes that retrieval available for entities
that predate the capability.
- Every candidate is unconditionally re-embedded on each call. There is no cheap way to tell
“already has a real embedding” apart from “still carries the schema migration’s zero-vector
placeholder” from a stored
FLOAT[]value alone, so re-running this backfills the same rows again. This is safe (idempotent in effect) but not free (an embedder round-trip per candidate, same asknowledge_backfill_relation_typesand subject to the same no-batching caveat, #445) — usedry_run: truefirst to see the candidate count. - Holds an exclusive lock for the whole run, blocking all other reads and writes. Unlike most
admin operations, the summary-vector HNSW index is dropped for the run’s duration and rebuilt
at the end — this is the only way an indexed embedding column can be refreshed for existing
rows at all (a plain
SETon an HNSW-indexed column is rejected once the index exists). Prefer running this at a low-traffic time, especially for a large group. group_idis required (matchingknowledge_backfill_relation_types’s convention): candidate selection and WAL attribution are both restricted to that one group; an omitted,null, or emptygroup_idis rejected rather than running database-wide or against the default group.- A partially-completed backfill is not an error. Entities not yet processed simply retrieve via existing name/lexical behavior, exactly as before this issue — running the tool again covers more of the group each time.
summary_embeddingstays write-once after backfill, same as on creation. A laterknowledge_assert_entityre-assert that changes an entity’ssummarydoes not refresh itssummary_embedding— the vector reflects whichever summary was embedded last (at creation, or at the most recent backfill run), not necessarily the currentsummarytext. Re-run this tool to bring a changed summary’s vector back in sync.
Cross-group pointers (add_cross_group_edge, rebind_pointers)
knowledge_add_cross_group_edge creates an edge whose endpoint(s) may live in a group_id
other than the edge’s own — the hub/layer-graph topology introduced by issue #369 (see
ADR-0369). Every intra-group edge write
(knowledge_add_episode, knowledge_process_chunk, and every other existing write path) is
completely unaffected: pointers only ever exist on edges created through this tool.
- Each endpoint is either a bare UUID or a name to resolve.
{"uuid": "..."}names an entity already known to live in the edge’s owngroup_id— no resolution, no pointer.{"source_group_id": "...", "endpoint_name": "..."}names a foreign endpoint: it is resolved by case-insensitive name lookup against that group (the same authorityget_entity_by_name_ci_with_scan_fallbackuses for extraction-time endpoint resolution, per ADR-0283), and the edge carries across_group_pointers.{src,dst}object recording the assertion (source_group_id,endpoint_name) and the resolution cache (resolved_uuid,bound_at_seq,binding_state). - A foreign endpoint that doesn’t currently resolve is
unbound, not dropped. Unlike ordinary extraction (which hard-drops an unresolvable endpoint at commit — see ADR-0051), the edge is still created; only the hop to that side is missing until a laterknowledge_rebind_pointerscall resolves it. Abinding_stateofambiguousmeans more than one entity currently matches the name — also retained, also missing that hop, never a silently-guessed winner. - A bare-UUID endpoint that turns out to belong to a different group than the edge is rejected before any write happens — this is what keeps a cross-group edge from silently losing its pointer fields the first time a caller passes a UUID instead of a name.
knowledge_rebind_pointers({"source_group_id": "..."}, required) re-resolves every pointer whosesource_group_idmatches, after that source group’s own hydration, incremental replay, or refresh cycle — including an ordinaryknowledge_rebuild_from_waltargeting that one group, not only a full purge-and-rehydrate (issue #378). A pointer currentlyboundis skipped once itsbound_at_seqis already at or pastsource_group_id’s own applied WAL position — never any other group’s, even when the edge carrying the pointer lives in a third, different group — this staleness gate is what makes a second call with no intervening change tosource_group_id’s stream a true no-op for pointers that are already correct. A pointer currentlyunboundorambiguousis always re-resolved regardless ofbound_at_seq(issue #392): a known-broken pointer is repaired unconditionally, since the position comparison alone cannot tell “nothing changed” apart from “the source group was purged and then restored to the same position the pointer was originally bound at.” A resolution that would create a self-loop or duplicate an existing directed edge invalidates the edge instead of writing a broken or redundant one, reusingknowledge_merge_entities’s own self-loop/dedup handling rather than a new policy. Returns{checked, bound, unbound, ambiguous, invalidated_self_loop, invalidated_duplicate, staleness_skipped}—staleness_skippedcounts pointers skipped by the gate above, distinct fromchecked(pointers actually re-resolved), so achecked: 0result is never ambiguous about whether anything was examined.- Unbound and ambiguous edges are excluded from normal reads. Every existing two-hop
traversal, search, and MCP read path requires both hops to exist — a pointer that hasn’t
resolved is invisible the same way any other incomplete edge would be, no special-casing
needed. Aggregate counts are visible via
knowledge_status’scross_group_pointers: {bound, unbound, ambiguous}field, so a refresh in progress is observable without a dedicated inspection endpoint.
Ingestion results (process_chunk)
knowledge_process_chunk reports what it could not write, not only how much. Two additive
result fields exist for that, both introduced in 0.13.2.
-
dropped_edgesreports the edges behindedges_dropped_unresolvable. An extracted edge whose source or target endpoint resolves to no entity — neither in the current extraction batch nor in the persisted graph — is dropped rather than written (ADR-0051).edges_dropped_unresolvablecounts those drops;dropped_edgesdescribes them, with one entry per counted edge, in extraction order:{"edges_dropped_unresolvable": 1,"dropped_edges": [{"source_name": "Ada Lovelace","target_name": "Analytical Engine","relation_type": "WROTE_NOTES_ON","fact": "Ada Lovelace wrote the first published algorithm for the Analytical Engine.","unresolved_endpoint": "target"}]}unresolved_endpointis"source","target", or"both".relation_typemay benull, mirroring the fact that it is already optional on an extracted edge before resolution is attempted. The dropped edge’s content is not persisted anywhere, so this result is the only place it appears — a consumer that wants to tell a user which fact was lost must read it here rather than recover it later.dropped_edgesis always present, an empty list when nothing was dropped, so it can be iterated unconditionally.edges_dropped_unresolvable’s existing meaning is unchanged, and a caller reading only the count is unaffected (issue #411). -
warningreports oversized input. Achunk_textlonger than the advisory threshold (LCG_CHUNK_TEXT_ADVISORY_MAX_CHARS, default 8,000 characters — see Configuration) adds awarningfield naming the actual and recommended character counts, and emits achunk_text_oversizedtelemetry event. This is visibility only: nothing is truncated, split, or rejected, and the call succeeds exactly as it did before. Splitting oversized input is the caller’s responsibility (issue #407).
Progress notifications
The six long-running operations — knowledge_rebuild_from_wal, knowledge_canonicalize_relations,
knowledge_backfill_relation_types, knowledge_backfill_summary_embeddings,
knowledge_reprocess_relation_types, and knowledge_reprocess_entity_types — bridge to MCP
progress notifications when the client
attaches a progress token to the tools/call request (_meta.progressToken), in both
standalone and attached mode. Without a progress token, these calls simply block until they
complete, same as over the socket protocol. In attached mode, each progress notification also
re-arms LCG_ATTACHED_CALL_TIMEOUT_MS’s per-read-line idle timer (see
DB-access modes above), so a progress-tracked call isn’t falsely reported as
timed out just because it runs longer than that timeout in total.
Example MCP client config
{ "mcpServers": { "liminis-context-graph": { "command": "liminis-context-graph", "args": ["--mcp-stdio", "--scope=read,write"], "cwd": "/path/to/your/workspace" } }}To attach to an already-running socket service instead of opening the DB directly:
{ "mcpServers": { "liminis-context-graph": { "command": "liminis-context-graph", "args": ["--mcp-stdio", "--connect", "/path/to/your/workspace/.lcg/service.sock", "--scope=read"] } }}See ADR 0035 for the transport’s internal architecture.
Documents liminis-context-graph v0.13.3.