Windows desktop app · Go · Wails + React · MCP server

dllm-network

Capturing

A DevTools Network tab for your local Ollama.

It watches the HTTP traffic between your tools and a local Ollama and shows it like a request inspector. Any MCP client reads that capture, read-only, so an LLM can answer questions about your inference.

Per request

  • Model
  • Endpoint
  • Status code
  • Tokens per second
  • Latency
  • Full body
3
MCP tools
0
MCP resources
5000
inference rolling cap
4
linter-enforced boundaries
Response
Real capture of the running app. Every number in the strip was read out of the response the tool actually observed, and the 1017 events behind the body are the individual streamed chunks it reassembled.
Capture

It sees everything talking to Ollama, including software you did not write

It reads the traffic itself, so an editor extension, a closed-source client and your own script all show up the same way. Every one of them keeps the configuration it already has.

Capture is packet inspection through WinDivert, at the network layer, below the application that made the request. The app reassembles the TCP stream, parses the HTTP exchange and derives its metrics from the response it actually saw.

  • Works on software you never wrote

    Your project keeps its own dependencies and imports untouched, so any tool on your machine is observable exactly as it ships. An editor extension or a closed-source client shows up on the same terms as your own code.

  • Your inference keeps its original speed

    Capture reads a copy off the wire, outside the request path. Your request reaches Ollama on the route it always took, at the speed it always had, and an inspector that stops running leaves the inference running.

  • Nothing to set up, nothing to undo

    Your client keeps pointing at 127.0.0.1:11434. Open the app and traffic appears; close it and your setup is exactly as you left it.

  • Byte-for-byte fidelity

    What you inspect is what Ollama received. The request object your code built travels untouched, and the payload on screen is the one that crossed the wire, down to the streamed chunks, reassembled in order.

Headers
Both header sets, read off the wire. Transfer-encoding chunked and the ndjson content type are the actual bytes Ollama sent, and they are also how the tool knows the response was streamed.
A foreign client
A separate session: this request came from an editor extension on a different endpoint, observed exactly as it ships, and its full system prompt is readable here.
Provenance

Every number on screen was read out of a response

Every figure here is derived from the response the app captured, so two runs can be compared and the difference between them is real. A field with nothing to read from shows a dash until the response lands.

In progress
12:23:38, in progress. Four fields hold a dash, because the response they are read from has not arrived.
Completed
12:24:09, completed. The response landed, and the same four fields now carry the numbers taken from it.

One request, thirty-one seconds apart. The four fields wait until there is something to read, so a number on screen tells you the measurement exists.

FieldWhere the value comes from
Request latencyMeasured across the captured exchange
Token countsRead out of the response body
Tokens per secondDerived from those two, never estimated
HTTP status codeObserved on the wire
Request and response bodiesStored verbatim, up to 16 MiB
Streaming chunksReassembled in order: 1017 of them in the capture above
Loaded modelsConfirmed against the Ollama API

What that buys you

  • Two runs can be compared directly, because both figures were measured the same way.
  • A slow request is genuinely slow, timed across the exchange itself.
  • Measured telemetry stays its own category, so a figure never quietly mixes a reading with an estimate.
  • Anything the app infers from indirect signals arrives labelled, with a confidence and the observations behind it.
The MCP surface

Three tools, called in order, each one cheap

The contract is staged, so a client spends context only on what it asked for. Each call narrows the question the next one answers, and a body arrives once the model has picked a single stable id.

  1. 01resolve_inference_context

    Orientation. What models, endpoints and statuses exist at all, and over what time range.

    Inputs

    • ()

    Outputs

    • models[]
    • endpoints[]
    • statuses[]
    • timeRange
    • counts.total
    • supportedFilters

    Cheap because: Aggregates only. It never includes a body, a header or any per-inference detail.

  2. 02search_inferences

    Narrowing. Page through candidates under filters combined with AND.

    Inputs

    • model
    • endpoint
    • status
    • since
    • until
    • limit ≤ 100
    • cursor

    Outputs

    • items[].id
    • at
    • model
    • endpoint
    • method
    • status
    • statusCode
    • streaming
    • promptSize
    • nextCursor

    Cheap because: Summaries carry stable fields only; heavy headers and bodies are omitted by construction.

  3. 03get_inference_context

    Detail, bounded. One known id, only the sections asked for, and a body read in slices.

    Inputs

    • id
    • sections[] ⊂ {metadata, tokens, request_headers, response_headers}
    • body{name, offset, limit}

    Outputs

    • availableSections
    • requested sections
    • bodyChunk{offset, nextOffset, hasMore, totalBytes, truncated}

    Cheap because: The client sets the byte window, so a large body arrives across several calls, in the slices it asked for.

Stable pagination

  1. Primaryat DESC

    The most recent inference first.

  2. Tie-breakerid DESC

    Several inferences can share a timestamp, so this holds a page boundary steady across two identical queries.

The cursor carries

  • at
  • id
  • Active filters

A cursor replayed under different filters is rejected.

Registering the sidecar

{
  "mcpServers": {
    "dllm-network": {
      "command": "C:\\path\\to\\dllm-network-mcp.exe"
    }
  }
}

The sidecar resolves the database location itself. Register it by absolute path and restart the client.

The seam

Two processes, one file, exactly one writer

The GUI and the MCP sidecar are two separate OS processes with separate lifetimes: you start the GUI, and your MCP client starts the sidecar whenever it needs it. They meet at one SQLite file on disk, which is what lets a single store span that gap.

The file both processes open

%LOCALAPPDATA%\dllm-network\telemetry.db
  • The GUI

    Read / write
    dllm-network

    DSN options

    • _pragma=journal_mode(WAL)
    • _pragma=busy_timeout(5000)
    • _txlock=immediate

    Opened once per session through sqlite.Open. It is the only connection allowed to write, and writes arrive batched from a separate drain goroutine so the capture loop never waits on disk.

  • The MCP sidecar

    Read only
    dllm-network-mcp

    DSN options

    • mode=ro
    • _pragma=query_only(true)
    • _pragma=journal_mode(WAL)
    • _pragma=busy_timeout(5000)

    A standalone stdio binary with no flags, launched by your MCP client. It resolves the database path through the same shared resolver the GUI uses, so the two can never disagree about where the file is.

The sidecar physically cannot write

Anything you connect to the sidecar (Claude Desktop, Claude Code, your own client) reaches your telemetry through a connection SQLite itself refuses to accept a write statement on, and a regression test keeps it that way. Point an LLM at your inference history knowing the worst it can do is read it.

Guarantees

Four properties the build proves on every commit

Each one is a property of the shipped binary, because the linter stops a build that would break it.

  • The MCP server can only ever read

    The read side depends on a reader port, so "read-only" is a shape of the program. That is what makes the sidecar safe to hand to an LLM.

    Enforced bymcp-not-captureinternal/mcp/**
  • Protocol churn stays in one package

    The MCP SDK exists in exactly one place, so a breaking change upstream has one package to land in. Every other package builds and tests without the SDK present at all.

    Enforced bysdk-confined-to-mcpeverywhere except internal/mcp/**
  • The domain model is testable on its own

    The inference type stays free of any storage driver, so a test can exercise the domain with nothing running behind it. How an inference is stored is entirely the sqlite package’s business.

    Enforced byinference-domain-purityinternal/telemetry/inference/**
  • A second transport costs no domain changes

    The same type is kept free of the MCP SDK, so what a protocol wants an inference to look like never reaches the model. Adding an HTTP transport beside the stdio one touches no domain code.

    Enforced byinference-domain-purityinternal/telemetry/inference/**

Every rule name above is a line in the repository’s linter configuration, so each boundary here is one you can go and check.

Build it

Run the app, then point a client at the sidecar

One repository, two artefacts: the tray app that captures, and the stdio binary that serves what it captured.

$ go build -o dllm-network-mcp.exe ./cmd/dllm-network-mcp
Open the repository

What it needs

Windows
Capture is a WinDivert driver, and the app runs from the tray.
Administrator
For per-request packet capture. Without it the app degrades to API polling.
A local Ollama instance
By default at http://127.0.0.1:11434.
Go 1.26+, Bun and the Wails v2 CLI
To build from source.
One run of the GUI first
The sidecar reads the database that run creates.
  • Disble/dllm-network

    The tray app, the MCP sidecar and the capture pipeline. Apache 2.0.

  • docs/ARCHITECTURE.md

    The data flow, the WAL seam and the four enforced boundaries, in the repo’s own words.

  • docs/mcp.md

    Build, register and use the MCP server; the three-tool reference and troubleshooting.

  • docs/passive-telemetry.md

    Confirmed-versus-inferred semantics, and how each field is labelled in passive mode.