EKOS + local LLM · driven over real MCP stdio JSON-RPC

"What is the reason for high visiting in some day?"

Not a question one query can answer. ekos_clickhouse_query (RFC 0056) is single-shot by design — one question, one generated SELECT, one result, no multi-turn loop. Answering why a day spiked means an MCP client chaining several of those single-shot calls, using each real answer to decide the next question — the same way this page was produced: four real tools/call requests, sent over stdio to a live ekos mcp serve --workspace analytics/ process, against a real ClickHouse server.

Who does the reasoning here?

Three layers, three different jobs. Worth being precise about which one does what.

MCP CLIENT (Claude)
Decomposes "why" into a sequence: find the spike day, then compare its traffic mix to normal. Decides what to ask next based on each real answer.
EKOS + LOCAL LLM
Per call: turns one NL question into one grounded SQL query, using the compiled schema — nothing more. No memory of prior calls.
CLICKHOUSE
Validates and executes the SQL for real. Rejects what it can't parse or run — the safety net when the LLM writes something wrong.

The investigation, four real MCP calls

Unedited. Including the one that failed.

1
tools/call · ekos_clickhouse_query"show visit counts grouped by day between 2026-08-01 and 2026-08-15, ordered by count descending"
SELECT toDate(start) AS date, count() AS visit_count
FROM sessions_v2 WHERE site_id = 101
  AND start >= '2026-08-01' AND start <= '2026-08-15'
GROUP BY todate(start)  ← lowercase, not the toDate() above
ORDER BY visit_count DESC LIMIT 1000
Code: 46. DB::Exception: Function with name todate does not exist. Maybe you meant: ['toDate','DATE']. (UNKNOWN_FUNCTION)
1b
retry, rephrased — the MCP client noticed the failure and adjusted"count sessions per calendar day using toDate(start) as the day column, order by the count descending"
SELECT toDate(start) AS day, COUNT(*) AS session_count
FROM sessions_v2 WHERE site_id = 101
  AND start >= '2026-08-01' AND start <= '2026-08-15'
GROUP BY toDate(start) ORDER BY session_count DESC LIMIT 1000
DayVisits
2026-08-054
2026-08-144
2026-08-01 / 02 / 04 / 06 / 082 each
7 other days1 each
2
tools/call · using the spike day just found"on 2026-08-05, group sessions by referrer_source and count them, ordered descending"
SELECT referrer_source, COUNT(*) AS session_count
FROM sessions_v2 WHERE site_id = 101 AND toDate(timestamp) = '2026-08-05'
GROUP BY referrer_source ORDER BY session_count DESC LIMIT 1000
Source (Aug 5)Sessions
Email newsletter2 (50%)
Twitter2 (50%)
3
tools/call · the other spike day, for corroboration"on 2026-08-14, group sessions by referrer_source and count them, ordered descending"
Source (Aug 14)Sessions
Email newsletter2 (50%)
Twitter1 (25%)
google.com1 (25%)
4
tools/call · the baseline to compare both spike days against"between 2026-08-01 and 2026-08-15, group sessions by referrer_source and count them, ordered descending"
Source (whole period)Sessions
Twitter13 (50%)
Email newsletter8 (31%)
LinkedIn2 (8%)
google.com2 (8%)
news.ycombinator.com1 (4%)

The same finding, visually

Both spike days — not just one — show Email newsletter's share roughly doubling against its own baseline.

Visits per day, Aug 1–15

Spike days highlighted

Email newsletter's share

Baseline vs. each spike day

The answer

Synthesized from the four real results above

Two days — Aug 5 and Aug 14 — had roughly 2–4× the visits of a typical day in the period. On both, Email newsletter traffic made up 50% of sessions, against its normal ~31% share across the full 15-day window. Twitter, the largest channel overall (50% baseline), didn't over-index on either spike day. The pattern repeating across two independent days, not just one, is what makes this a real signal rather than noise: an email send is the most likely driver of the higher-than-usual visiting days, not a general lift across all channels.

Sample-size honesty: this is a small, synthetic demo dataset (26 sessions total) built to exercise the real pipeline, not production traffic — 4 visits is a real spike relative to this dataset's own baseline, but not a claim that would hold up at production scale without more data. The mechanism demonstrated (decompose → find the outlier → compare its mix to baseline → corroborate on a second instance) is what transfers to a real deployment, not these exact percentages.

Under the hood: Claude, MCP, and EKOS

The three-layer summary above is accurate but compressed. Here's what actually crosses the wire and runs inside ekos mcp serve for a single ekos_clickhouse_query call.

One tools/call, start to finish. Everything inside the dashed line runs in the one ekos mcp serve process, on this machine, with no other network egress.

The transport: stdio, not a socket

ekos mcp serve --workspace analytics/ is one OS process. Claude Code launches it as a child process and talks to it over its stdin/stdout pipes — no port, no HTTP, no auth handshake beyond MCP's own initialize. The server's entire request loop is for line in stdin.lock().lines(): read one line, parse it as a JSON-RPC 2.0 object, dispatch on method, write exactly one response line to stdout, repeat. notifications/initialized is the one message that gets no reply, per the MCP spec — it's a notification, not a request. This is also why the process is disposable: it holds no session state between calls beyond what's on disk (the ledger, the compiled schema) — every tools/call opens what it needs fresh.

Inside one tools/call

The five internal stages in the diagram above are RFC 0056's own pipeline, unchanged from the CLI path (ekos clickhouse ask and the MCP tool share the same ask_clickhouse function — the only difference is which process calls it):

StageWhat actually happens
Schema contextNot a live ClickHouse call. Runtime::find_objects + load_neighborhood over the already-compiled ledger, filtered to ObjectKind::Table with properties["source_system"] == "clickhouse". This is why schema drift between two ekos build runs is a real, named tradeoff in RFC 0056 — the question is answered against whatever was last compiled, not the live table right now.
Build SQLOne LlmProvider::complete call, temperature: 0 always, system prompt constrained to ClickHouse SQL and only the tables/columns just retrieved. This is the only stage that talks to Ollama, and the only stage that can hallucinate.
Validate + executeThe generated text is parsed with ekos-plugin-sql-dialect-clickhouse's real ClickHouseDialect (the same dialect this session's own RFC 0057/0058 work fixed two real gaps in). Anything that isn't exactly one Statement::Query is rejected before it ever reaches ClickHouse — no INSERT/DROP/multi-statement batch gets this far. A missing LIMIT is injected into the parsed AST, not string-pasted. Only then does it run, over HTTP, against the real server.
Redact + auditekos_common::redaction::redact_json runs over every returned row before anything leaves the process — the same RFC 0043 baseline every other ingestion path uses, applied here to live query results for the first time (RFC 0056's own new integration point). Then, only on success, one Event and one Evidence record are appended to the local ledger: the SQL text, a timestamp, the row count, and a content-hash of the result. The rows themselves are never written to the ledger.

The gate is checked twice, not once

ekos_clickhouse_query only appears in tools/list's response when [clickhouse] enable-mcp-query = true is set — this workspace's ekos.toml opted in explicitly for this test. But the same flag is re-checked independently inside tools/call's dispatch, because an MCP client can call a tool by name directly without ever having listed it first. Both paths are load-bearing: hiding a tool from discovery isn't the same guarantee as refusing to run it.

Where the reasoning actually lives

Nothing in the five stages above has any memory of a previous call, and nothing in ekos mcp serve decides to retry. When Step 1's GROUP BY todate(start) failed, EKOS's only job was finished the moment it returned that ClickHouse error string as the tool's result. Every decision after that — noticing the failure, guessing the cause, rephrasing the question, deciding which table/column pairing to interrogate next based on what the previous real answer said — happened in the MCP client, reading the same JSON-RPC responses a human would read off this page. EKOS's local LLM is doing real work in every call (English → grounded SQL), but it is not the layer answering "what is the reason" — that question only gets answered by something outside this process chaining several of these calls together and comparing the results, which is the whole reason this page exists instead of a single query.

A gotcha this exact wiring hit before

Not from this session, but worth knowing if you extend this: ekos mcp serve's for line in stdin.lock().lines() loop runs directly inside main's #[tokio::main] runtime — it's never spawned onto its own task. ask_clickhouse's pipeline is async. Calling an async function from inside an already-running multi-thread Tokio runtime with a naive Runtime::new().block_on(...) panics: "Cannot start a runtime from within a runtime." The fix (already in place, not something this test needed to touch) branches on tokio::runtime::Handle::try_current(): bridge via tokio::task::block_in_place when already inside a runtime, build a throwaway one otherwise. No unit test in this codebase runs from inside an active Tokio runtime the way ekos mcp serve's real entry point does — this class of bug is only found by actually running the command, the same discipline this whole page's live MCP test follows rather than trusting cargo test alone.

Raw MCP transcript (excerpt) — real, unedited stdio JSON-RPC
$ ekos mcp serve --workspace analytics/
>> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}
<< {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","serverInfo":{"name":"ekos","version":"0.1.0"},"capabilities":{"tools":{}}}}

>> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
<< tools: [..., "ekos_clickhouse_query"]   # present only because [clickhouse] enable-mcp-query = true

>> {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ekos_clickhouse_query",
     "arguments":{"question":"count sessions per calendar day using toDate(start) as the day
     column, order by the count descending"}}}
<< {"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":
     "{\"sql\":\"SELECT toDate(start) AS day, COUNT(*) AS session_count FROM
     plausible_events_db.sessions_v2 WHERE site_id = 101 AND start >= '2026-08-01'
     AND start <= '2026-08-15' GROUP BY toDate(start) ORDER BY session_count DESC LIMIT 1000\",
     \"rows\":[{\"day\":\"2026-08-05\",\"session_count\":\"4\"}, ...],
     \"audit_event_id\":\"67510ea2-7285-4798-81f9-47b1b4c832e1\"}"}]}}