Inspecting a running graph over MCP
Ranvier.Mcp exposes named values and authorised Elmish messages from a running application to an MCP client. The client evaluates no code.
In a traced build, explain, graph and node answer from the whole graph's trace log in compact text. Expose.node registers names a client can pass to node; it does not restrict trace visibility.
The runnable sample, a cart over stdio, is examples/Ranvier.Mcp.Sample. Its README shows how to register it beside SageFs.
Hosting the graph
GraphHost.Start setup starts a dedicated thread, builds a Guarded graph on it, and runs setup there. The host's inbox drains on that thread, so Graph.DispatchAsync from a transport thread completes without a pump.
type Msg = Add of int
type App =
{
Mvu: Mvu<int, Msg>
Exposure: Exposure
Adapter: McpAdapter
}
let decoders: (string * MessageDecoder<Msg>) list =
[ "add", fun args -> Result.Ok (Add (args.GetProperty("amount").GetInt32 ())) ]
use host =
GraphHost.Start (fun _ ->
let mvu = Mvu.create 0 (fun (Add n) count -> count + n)
let count = mvu.Select id
let exposure = Exposure ()
exposure |> Expose.value "count" (fun (n: int) -> JsonValue.Create n :> JsonNode) (fun () -> count.Value)
exposure |> Expose.messages mvu decoders
exposure |> Expose.node "count" count
exposure.Start ()
{ Mvu = mvu; Exposure = exposure; Adapter = new McpAdapter (exposure) })
let adapter = host.Invoke(_.Adapter).Result
let transport = new StdioServerTransport ("counter", NullLoggerFactory.Instance)
let server = McpServer.Create (transport, adapter.Options)
server.RunAsync(CancellationToken.None).GetAwaiter().GetResult()
server.DisposeAsync().AsTask().GetAwaiter().GetResult()
host.Invoke(fun app -> app.Adapter.Dispose ()).Wait()
host.Invoke workrunsworkon the graph thread with the setup's value and returns a task of its result, once the work and the effects it triggers have run. It is the only way into the graph from another thread. Aworkthat throws faults the task. Called on the graph thread, the work runs beforeInvokereturns.host.Invoke (work, cancellationToken)cancels the task instead when the token is cancelled before the work starts.host.GraphThreadIdis the managed thread id of the graph thread.- A node touched from any other thread raises
InvalidOperationException.GraphHost.Start (options, setup)builds the graph fromoptionswith the host's own dispatcher, and raisesArgumentExceptionfor aThreadAffinityother thanGuarded. - An exception from
setupis rethrown byStart, after the host thread has stopped. host.Dispose ()disposes the graph on its thread, after any work already running, and returns once the thread has stopped. After disposal,Invokefaults withObjectDisposedException, and so does work still queued. It is idempotent.- Construct the
McpAdapteron the graph thread, afterexposure.Start ().adapter.Optionsis safe to use from any thread, and several servers may share it; each server's subscriptions are its own.adapter.Dispose ()stops notifying subscribers and is idempotent.
Exposing state
Register everything inside the setup, before exposure.Start ():
| Registration | Exposes |
|---|---|
Expose.value name encode read | read (), encoded to JSON by encode, as the value name |
Expose.messages mvu [ name, decoder ] | each message for dispatch; a MessageDecoder<'Msg> is JsonElement -> Result<'Msg, string> |
Expose.messageShapes [ name, shape ] | a one-line description of each message's arguments, shown in dispatch's description and in its malformed-argument errors |
Expose.node name node | node to the traced provenance tools; no effect in an untraced build |
Names use [A-Za-z0-9_.~-] and are unique within their kind. Start closes registration and creates one effect that reads every value. The effect publishes a Snapshot { Version; Values }:
- Every value in a snapshot comes from the same flush.
- A flush that changes any encoding publishes one snapshot, one version higher. A batch publishes once.
- A memo cutoff, an equal write, or a re-run whose encodings all match the previous snapshot publishes nothing.
exposure.Snapshot is safe to read from any thread and does not enter the graph.
Reading and dispatching in process
Application code and tests can call the same operations through Exposure:
| Member | Result |
|---|---|
exposure.Dispatch (name, args) | a task of Ok snapshot, published once the message and its effects have run, or Error reason |
exposure.WaitVersion (version, timeout) | a task of Some snapshot, the first at or past version, or None once timeout elapses |
exposure.Published | an event of Publication { Snapshot; Changed }, where Changed lists the names whose encoding changed, in registration order |
exposure.Names, exposure.MessageNames, exposure.Nodes | the registered values, messages and name/node pairs, in registration order |
Dispatch and WaitVersion are callable from any thread after Start. Dispatch decodes on the graph thread; a rejected message dispatches nothing, and the task faults when update raises. WaitVersion does not enter the graph. Published is raised on the graph thread, inside the publishing flush, after Snapshot holds the new snapshot, so subscribe to it through host.Invoke:
let run (host: GraphHost<App>) =
task {
do! host.Invoke (fun app ->
app.Exposure.Published.Add (fun publication ->
printfn "version %d changed %A" publication.Snapshot.Version publication.Changed))
let! exposure = host.Invoke _.Exposure
let args = JsonDocument.Parse("""{"amount": 2}""").RootElement
match! exposure.Dispatch ("add", args) with
| Result.Ok snapshot -> printfn "count is %O at version %d" snapshot.Values["count"] snapshot.Version
| Result.Error reason -> printfn "rejected: %s" reason
let! later = exposure.WaitVersion (exposure.Snapshot.Version + 1L, TimeSpan.FromSeconds 1.0)
printfn "%A" (later |> Option.map _.Version)
}
Resources
| URI | Document |
|---|---|
| ranvier://state | {"version": n, "values": {name: value}} |
| ranvier://state/ | {"version": n, "value": value} |
Both support resources/subscribe. Each publication sends notifications/resources/updated for ranvier://state and for each value whose encoding changed. The adapter pins MCP protocol 2025-11-25, since 2026-07-28 replaces resources/subscribe with subscriptions/listen.
Tools
| Tool | Arguments | Result |
|---|---|---|
snapshot | none | the ranvier://state document |
dispatch | message: an exposed name, and args: JSON for its decoder; or messages: [{message, args}, ...] | per message, the exposed values it changed; then every exposed value at the final version |
wait_version | version; timeoutMs (default 5000, at most 60000) | the first snapshot at or past version |
dispatch is listed only when messages are exposed, and its input schema enumerates them. Each decoder runs on the graph thread, and the messages run in order, each after the previous one's effects. A step reads 2 set_discount {"percent":10} → v3: discount 0→10, and the last line holds every exposed value. The snapshot and wait_version results are the JSON document as text and as structured content. dispatch returns text alone: Claude Code shows a model the structured content in place of the text, which would hide the step lines.
A rejected call is a tool result with isError: true and the reason as text:
dispatch: an unknown message, a missing or non-stringmessage, a decoderError, or a decoder that raises (for example, a missing property). The decoder'sErrortext is the reason, followed by the message's shape when one is described. In a sequence, the error names the rejected message's position; the messages before it stay dispatched, and the error lists their steps.wait_version: a non-integerversion, a negativetimeoutMs, or a timeout.
An exception from update is also a tool error; the model write it interrupted does not happen.
Traced builds
With RANVIER_TRACE (Debug builds, or -p:RanvierTrace=true), the adapter also lists these tools. Each reads the trace log on the graph thread and answers in compact text:
| Tool | Arguments | Answer |
|---|---|---|
explain | node; messages, as in dispatch | the dispatched steps, then why the node holds its value, in at most a few lines |
graph | none | node counts by kind, the exposed nodes, and the nodes the last message moved; at most 2,000 characters |
node | node; around (0 to 2 hops, default 1); detail (brief or full); limit (default 25); cursor | the node and its sources and observers within around hops, one line each; at most 4,000 characters a page |
A node reads as name#id: the name registered with Expose.node, a Trace.label, or the let binding at the node's creation site. Each line gives the node's kind, its creation site as file:line, its value and its sources, for example total#5 Memo Cart.fs:155 = 1200 reads [subtotal#4]. When the source root holds exactly one file of the site's name, the answer adds the line's text. node takes an exposed name, a node name, #id or an identity path; a miss is a tool error that lists the exposed nodes.
explain answers, for one node:
- its creation line, its value and the sources it reads;
- the cause chain of its last run, from the dispatched message through each write and move;
- why it has not re-run since: checked clean against named sources, propagation stopped upstream, unobserved, or queued;
unread:nodes related to it that changed after its last run and that it does not read, such as a value read withPeek;cutoff:upstream nodes that re-ran after a source moved and kept their value, with the value kept, such as a memo whose comparer calls a new result equal. Without one since the node's last run,earlier cutoff:lines name the latest ones before it. A node that reads a dispatched model alone, such as anMvu.Select, keeps its value by design and is left out;comparer:when no cutoff is reported, up to two upstream nodes whose creation line, or the line after it, names a comparer (createMemoWithComparerand the other...WithComparerbuilders), with their values: the places a later message can be cut off.
Values are encoded without codecs, so a non-scalar value appears as its ToString text.
McpAdapterOptions configures the adapter, new McpAdapter (exposure, options):
| Field | Default | Effect |
|---|---|---|
RawTraceTools | false | also lists trace_state, trace_lookup, why, why_not and waiting_on, which return TraceJson documents; trace_state grows with the graph without bound |
SourceRoot | the nearest directory holding .git at or above the current directory | the directory whose F# and C# files give a creation line's text |
AlwaysLoad | false | marks dispatch and explain with _meta["anthropic/alwaysLoad"], so Claude Code loads them without a tool search |
The server's instructions make explain {node, messages} the first call of a diagnosis, then the fix of the named line. Always-loaded tools join the head of the prompt, so a session with AlwaysLoad starts with a cold prompt cache unless a session with the same tools ran within the cache lifetime: about 40,000 tokens written instead of read, against one tool-search request without it. An untraced build lists none of the traced tools, and calling one is an unknown-tool error.
Serving across rebuilds
A server started from a build's output directory holds its assemblies open, so a rebuild of the same output fails while the server runs. tools/mcp-supervise.fsx serves the application from a copy instead:
{
"mcpServers": {
"ranvier-cart": {
"type": "stdio",
"command": "dotnet",
"args": ["fsi", "--quiet", "tools/mcp-supervise.fsx", "examples/Ranvier.Mcp.Sample/bin/Debug/net10.0/Ranvier.Mcp.Sample.dll"]
}
}
}
On each tools/call, when the output's assembly is newer than the running copy, the supervisor starts the new build, repeats the client's initialize, and dispatches again every message accepted so far, as one dispatch {messages}. The tool result that follows a reload starts with a line naming the reload and the replay's final state. Resource subscriptions end at a reload. A reload waits until the assembly has kept its timestamp for half a second, which costs nothing when the build finished earlier; it then takes about 0.35 seconds for the cart sample.
Calling the tools from the shell
tools/mcp-call.fsx calls one tool of the application's build output and prints the result's text:
dotnet fsi --quiet tools/mcp-call.fsx examples/Ranvier.Mcp.Sample/bin/Debug/net10.0/Ranvier.Mcp.Sample.dll explain '{"node": "total", "messages": [{"message": "add_item", "args": {"sku": "a", "unitPrice": 250, "quantity": 2}}]}'
Each call starts the application, runs the handshake, makes the call and stops the application, in about 1.7 seconds for the samples. Two calls share no state, so explain {node, messages} and dispatch {messages} carry the whole sequence; a rebuild needs no supervisor. --list prints the server's instructions and each tool's description and argument names. The exit code is 1 for a tool error.
An agent reaches the tools through its shell, described in the project's CLAUDE.md or AGENTS.md, with no MCP server configured. The tool search before the first call goes, and a check after a fix runs in the same command as the build: dotnet build ... && dotnet fsi --quiet tools/mcp-call.fsx ... dispatch '...'. On the nine-file store task this took half the requests of the baseline without tools, and 0.60× its tokens; see Research on agents and the MCP tools.