Skip to content

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 work runs work on 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. A work that throws faults the task. Called on the graph thread, the work runs before Invoke returns.
  • host.Invoke (work, cancellationToken) cancels the task instead when the token is cancelled before the work starts.
  • host.GraphThreadId is the managed thread id of the graph thread.
  • A node touched from any other thread raises InvalidOperationException. GraphHost.Start (options, setup) builds the graph from options with the host's own dispatcher, and raises ArgumentException for a ThreadAffinity other than Guarded.
  • An exception from setup is rethrown by Start, 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, Invoke faults with ObjectDisposedException, and so does work still queued. It is idempotent.
  • Construct the McpAdapter on the graph thread, after exposure.Start (). adapter.Options is 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 ():

RegistrationExposes
Expose.value name encode readread (), 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 nodenode 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:

MemberResult
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.Publishedan event of Publication { Snapshot; Changed }, where Changed lists the names whose encoding changed, in registration order
exposure.Names, exposure.MessageNames, exposure.Nodesthe 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

URIDocument
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

ToolArgumentsResult
snapshotnonethe ranvier://state document
dispatchmessage: 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_versionversion; 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-string message, a decoder Error, or a decoder that raises (for example, a missing property). The decoder's Error text 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-integer version, a negative timeoutMs, 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:

ToolArgumentsAnswer
explainnode; messages, as in dispatchthe dispatched steps, then why the node holds its value, in at most a few lines
graphnonenode counts by kind, the exposed nodes, and the nodes the last message moved; at most 2,000 characters
nodenode; around (0 to 2 hops, default 1); detail (brief or full); limit (default 25); cursorthe 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 with Peek;
  • 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 an Mvu.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 (createMemoWithComparer and the other ...WithComparer builders), 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):

FieldDefaultEffect
RawTraceToolsfalsealso lists trace_state, trace_lookup, why, why_not and waiting_on, which return TraceJson documents; trace_state grows with the graph without bound
SourceRootthe nearest directory holding .git at or above the current directorythe directory whose F# and C# files give a creation line's text
AlwaysLoadfalsemarks 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.

Edit this page