Agents and MCP¶
The running server speaks the Model Context Protocol at /mcp. There is no
second process to start, no discovery file to write and nothing to install:
attaching an agent is pointing it at a URL that the launch line already printed.
The point of the feature is not “an agent can query a graph” — the graph’s own
kglite MCP server
does that better. The point is that the agent and the human are looking at
the same screen. Whoever changes the view — you, an agent, a curl — every
connected window sees the change immediately. Watching an agent expand a type
and zoom to what it found is the feature, not a side effect of it.
This page is written for someone wiring that up.
The launch contract¶
kglite-visual graph.kgl --no-open --port 0 &
--no-open is mandatory for anything unattended; --port 0 means OS-assigned.
The server binds 127.0.0.1 only.
Exactly one line on stdout, and it is JSON:
{"url":"http://127.0.0.1:54137/","port":54137,"pid":69850,"graph":"/path/graph.kgl","mcp":"http://127.0.0.1:54137/mcp"}
Parse it. Never scrape stderr, never race a hardcoded port. All diagnostics are on stderr, and the error path is exit 1 with empty stdout and one line on stderr — so a harness that read a line has a server.
BIN=kglite-visual
"$BIN" graph.kgl --no-open --port 0 > server.json 2> server.err &
B=$(python3 -c 'import json;print(json.load(open("server.json"))["url"].rstrip("/"))')
M=$(python3 -c 'import json;print(json.load(open("server.json"))["mcp"])')
From Python, the same five keys come back as a dict instead:
view = kglite_visual.show("graph.kgl", open_browser=False)
view.launch_info # {'url', 'port', 'pid', 'graph', 'mcp'}
view.close()
The wheel writes nothing to stdout — the single-line contract is the CLI’s, and a library that printed there would corrupt its caller’s output.
The JSON twin¶
Every request the WebSocket carries has a named HTTP route with the same
structs. A curl line therefore says what it asks for, and divergence between
the twin and the wire is a bug rather than a nuance.
C='content-type: application/json'
curl -s $B/api/session # protocol_version, tier, counts, bounds
curl -s $B/api/meta-graph # slots, edges, positions, bounds
curl -s $B/api/describe # schema tiers, per-type detail
curl -s $B/api/view-state # what is on screen right now
The mutating half is POST with a JSON body. POST rather than GET because
several of them mutate the slot space, and a GET that appended slots would be
re-run by any cache in the path.
curl -s -XPOST $B/api/preview -H "$C" -d '{"slot":0}'
curl -s -XPOST $B/api/expand -H "$C" -d '{"slot":0,"relationship":"KNOWS","direction":"out","limit":40}'
curl -s -XPOST $B/api/collapse -H "$C" -d '{"slot":0}'
curl -s -XPOST $B/api/node -H "$C" -d '{"slot":5}'
curl -s -XPOST $B/api/cypher -H "$C" -d '{"query":"MATCH (n) RETURN n LIMIT 3","params":{},"as_graph":false}'
curl -s -XPOST $B/api/search -H "$C" -d '{"query":"ada","node_type":"Person","property":"title"}'
curl -s -XPOST $B/api/property-stats -H "$C" -d '{"node_type":"Person"}'
curl -s -XPOST $B/api/validate -H "$C" -d '{"query":"MATCH (n:Persn) RETRN n"}'
curl -s -XPOST $B/api/layout -H "$C" -d '{"kernel":"islands"}'
curl -s -XPOST $B/api/render -H "$C" -d '{"source":{"type":"meta"},"format":"png"}' -o m.png
# steering — these three answer with {"clients":n}
curl -s -XPOST $B/api/focus -H "$C" -d '{"slots":[3,4,5]}'
curl -s -XPOST $B/api/highlight -H "$C" -d '{"slots":[3],"concept":"selected"}'
curl -s -XPOST $B/api/appearance -H "$C" -d '{"color_by":"city","size_by":"age"}'
# reset takes no body and answers with the slice it collapsed back to
curl -s -XPOST $B/api/reset
# the saved-query store
curl -s $B/api/queries
curl -s -XPOST $B/api/queries/save -H "$C" -d '{"name":"wells","query":"MATCH (w:Wellbore) RETURN w LIMIT 5"}'
curl -s -XPOST $B/api/queries/delete -H "$C" -d '{"name":"wells"}'
curl -s -XPOST $B/api/queries/history -H "$C" -d '{"query":"…"}'
# the one GET in the vocabulary: a download is an <a href download>, an anchor
# issues a GET, and this route reads the view and mutates nothing.
curl -sD- "$B/api/export?format=graphml&source=live-view" -o view.graphml
Three rules hold across all of it:
Every bounded response carries
{returned, total, truncated}. Report those numbers; do not hide them. A graph slice carries two —meta.boundfor nodes andmeta.link_boundfor links — because nodes and links share one byte budget, so the node list can be complete while the link list is not.A bad request is a
400that names what it refused.A query the engine rejected is a
422carrying kglite’s own diagnostic verbatim. Quote it; do not summarise it.
The steering endpoints — focus, highlight, appearance — answer
{"clients":n}, because a command that reached nobody is otherwise
indistinguishable from one that reached the user. Every MCP steering tool
carries the same number as connected_viewers.
MCP at /mcp¶
Streamable HTTP, mounted as one more route on the same axum router that serves the frontend, the JSON twin and the WebSocket.
M="$(python3 -c 'import json;print(json.load(open("server.json"))["mcp"])')"
H=(-H 'content-type: application/json' -H 'accept: application/json, text/event-stream')
# initialize first; keep the mcp-session-id header it returns, on every later call
S=$(curl -sD - -XPOST "$M" "${H[@]}" -o /dev/null \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
| grep -i '^mcp-session-id' | tr -d '\r' | cut -d' ' -f2)
curl -s -XPOST "$M" "${H[@]}" -H "mcp-session-id: $S" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -s -XPOST "$M" "${H[@]}" -H "mcp-session-id: $S" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
Responses default to text/event-stream. The JSON-RPC payload is a data:
line — but not necessarily the last one, since the stream opens with an empty
data: and a retry:. Parse it rather than tailing it:
mcp() { # mcp <tool> <json-args>
curl -s -XPOST "$M" "${H[@]}" -H "mcp-session-id: $S" \
-d "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}" \
| python3 -c 'import json,sys
for line in sys.stdin:
line = line.strip()
if line.startswith("data: ") and len(line) > 6:
d = json.loads(line[6:])
for c in (d.get("result", {}).get("content") or []):
print(c.get("text", ""))'
}
mcp view_state '{}'
mcp show_cypher '{"query":"MATCH (f:Field) RETURN f"}'
mcp set_layout '{"kernel":"geo"}'
Errors an agent can act on come back as isError: true with kglite’s own
message. Quote it; do not summarise it.
The thirteen tools¶
Ten are verbs about the screen. The two saved-query tools are the exception
that proves the rule — they read a store belonging to this window and to the
human who filled it, which is not a fact any other server has — and
export_view is the thirteenth, which takes what is on the screen out of the
screen.
Tool |
What it does |
|---|---|
|
What is on the shared screen right now: the slot space, type nodes and their drill-in state, instance counts by type, tombstones, what the response bound did to the last change, and |
|
Run read-only Cypher and put the resulting nodes and relationships into the shared view. Bounded in core. A display verb — to read a table, ask the graph’s own MCP server |
|
Load a slot’s neighbours. A type slot loads instances; an instance slot loads what it is connected to. |
|
Remove a slot’s expansion. Slot numbers are not reissued unless the answer carries a compaction, which renumbers everything and says so |
|
Make things stand out. Name |
|
Zoom the human’s camera to frame these slots — the honest way to say “look at this”. An empty list frames the whole view. Changes nothing about what is loaded |
|
Drive |
|
Re-arrange the view with a layout computed here, and hold it still. See what an agent may claim |
|
Collapse everything back to the entry screen. Destructive to the human’s place in the graph — prefer |
|
Draw an image. |
|
The Cypher this user saved for this graph, plus recently run queries. Read it before writing a query of your own |
|
Run one by name, into the shared view. Same path, same bound; added to the user’s recent list, because they are watching it happen |
|
Write the nodes currently in the view out as GraphML / GEXF / CSV / D3 JSON and hand back the text |
export_view takes the view, not the graph¶
Its scope is the instance nodes on the human’s screen. On an empty view it refuses by name rather than dumping the graph:
{"error":"there is nothing to export: no instance nodes are loaded. Expand a type or run a query with 'show in graph' first."}
So expand or show_cypher what you want first, check view_state, then
export — and read the notes in the reply before telling the user what
they have:
{"bytes":27033,"filename":"graph-view.gexf","format":"gexf","nodes":144,
"notes":["the nodes are exactly the ones selected; the edges are every edge this graph holds between them, which can be MORE than you saw …"]}
The two caveats are in export.
Saved queries first¶
list_saved_queries returns what this user decided was worth keeping, under
the names they chose, plus the last 20 queries run from the panel and the cap
on that list. Reading it before writing your own Cypher over a schema you have
just met is the difference between joining someone’s session and starting your
own next to it.
What an agent may claim¶
This is the part that is easy to get wrong and expensive when you do.
Content is knowable. Geometry depends on the layout.
While view_state.layout_kernel reads simulation — the default every session
opens in — the layout runs on the viewer’s GPU and the server never learns
where the points ended up:
The live layout runs on the viewer’s GPU and the server does not know where the points ended up (
layout_kernelissimulation). A render of this view is content-identical and geometry-different: same nodes, same links, same truncation, a different arrangement. Describe what is in the view, never where it is on the user’s screen — or ask for a static layout, after which the arrangement is this server’s own and can be described.
Use focus and highlight to say “look at this” instead of naming a position
you cannot see.
set_layout with a static kernel (auto, radial, islands, force, geo)
computes the arrangement here and broadcasts it. The viewer’s simulation stops,
dragging is disabled, and the caveat changes:
$ mcp set_layout '{"kernel":"geo"}'
{"connected_viewers":0,"geometry_caveat":"This view is under a static layout THIS
SERVER computed (`layout_kernel` names the kernel): the viewer's simulation is off,
dragging is disabled, and nothing moves a point until the next layout request. So
the arrangement on their screen is the one that was sent, and relative position is
safe to describe — 'the ring around X', 'the island on the left'. Their camera is
still their own, so never name a screen coordinate; and `render` lays out
independently (it folds fans and separates circles for the page it draws), so its
picture may still differ from what they see.",
"kernel_chosen":"geo","kernel_requested":"geo","layout_ms":0.13,"seed_slot":null,
"slots_placed":242}
Read geometry_caveat from the answer rather than remembering which mode you
are in. Core owns both wordings and picks by layout_kernel; nothing else
writes its own.
Three rules survive every mode:
The camera is always theirs. They zoom and pan freely, so a screen coordinate is never a claim you can make — only a relative one, and only under a static kernel.
kernel_chosencan differ fromkernel_requested.islandsover a graph with no community structure falls back toforceand says so.renderis a separate pass either way. It has its own fold and its own separation, sorender{target:"live-view"}can differ from the canvas even under a static kernel. Describe what is in the picture, never where it sits.
Under geo the arrangement genuinely is geographic, so “this node is in the
Barents Sea” is a claim the picture supports. “Top left of your screen” still
is not.
window.__kglv¶
The browser’s own state hook, for a driver in a real browser (Playwright,
Claude in Chrome, a console). Readiness is window.__kglv.ready === true —
never a fixed sleep: cosmos.gl v3 is async-init and draws zero frames when
static.
{"protocolVersion":4,"tier":"compact","layoutMode":"force","layoutKernel":"simulation",
"pointCount":98,"linkCount":124,"slotCount":98,"tombstoneCount":0,"namedSlots":98,
"ready":true,"simRunning":true,"lastMessageSeq":2,"positionsHash":"80499c25",
"deviceFeatures":{"webgl2":true,"float32Renderable":true,"textureBlendFloat":true},
"lastSliceKind":"sync","compactions":0,
"truncation":{"returned":0,"total":0,"truncated":false,"banner":null},
"zoomLevel":0.42,"focusedSlots":[],"colorBy":null,"sizeBy":null,"hoveredSlot":null,
"emphasizedCount":0,"highlightedCount":0,"selectedCount":0,"previewRows":0,
"queryRows":0,"searchHits":0,"legendEntries":4,"exportNodes":0,"filteredOut":0,
"appearanceCandidates":0,"approximateStats":0,"error":null}
Two of these are honest pairs, and neither half is honest alone:
pointCount+filteredOutpointCountis live points excluding whatever the client-side filter is hiding;filteredOutis that count.slotCountincludes tombstones.namedSlots+slotCountA client holds a position for every slot it was told about and an identity only for the ones whose
SliceNodeit received. These are unequal on any browser that joined mid-session, until the connect-time resync.
The rest, briefly: layoutMode is force / deterministic / static and
layoutKernel names the arrangement in force; positionsHash only means
anything where nothing is moving the points; truncation carries the banner
text the user is actually reading, so an assertion can check the words rather
than a boolean beside them; exportNodes is what the Export card would write;
error non-null explains any ready:false.
Assert on state; screenshots are artifacts.
A second hook, window.__kglvBench, carries exactly two fields a benchmark
harness cannot get from outside: graph (the live cosmos.gl instance) and
firstDataFrameMs. It ships in the production bundle on purpose — a hook
compiled out of the build being measured measures a different build.
Joining a session in progress¶
A browser that attaches to a view an agent has already drilled into is greeted with the whole current view — every live node named, holes marked, positions from slot zero, and the static arrangement in force if there is one.
That was not always true, and the failure was ugly: every client used to be greeted with the entry screen, so the next change arrived indexing slots that browser had never been told about, and the points appeared with no label, no id and nothing to click.
Stopping¶
Kill the pid from the launch line. kill -TERM (the default) is caught: the
server shuts down, exits 0, releases the port and removes kglite’s
temporary spill from $TMPDIR — 370 MB for a half-million-node graph.
kill -9 skips all of it and leaves the spill behind. Ctrl-C is the same
handler.
Verify the port is released before relaunching on a fixed port.
A note on scope¶
These tools navigate a graph. Querying one — schema, Cypher reference, result formatting, CSV export of a large answer — is kglite’s own MCP server’s job, and keeping the surface here small is what stops this becoming a worse copy of it, one tool at a time. Run both; they are complementary.