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.
Three layers, three different jobs. Worth being precise about which one does what.
Unedited. Including the one that failed.
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
todate does not
exist. Maybe you meant: ['toDate','DATE']. (UNKNOWN_FUNCTION)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
| Day | Visits |
|---|---|
| 2026-08-05 | 4 |
| 2026-08-14 | 4 |
| 2026-08-01 / 02 / 04 / 06 / 08 | 2 each |
| 7 other days | 1 each |
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 newsletter | 2 (50%) |
| 2 (50%) |
| Source (Aug 14) | Sessions |
|---|---|
| Email newsletter | 2 (50%) |
| 1 (25%) | |
| google.com | 1 (25%) |
| Source (whole period) | Sessions |
|---|---|
| 13 (50%) | |
| Email newsletter | 8 (31%) |
| 2 (8%) | |
| google.com | 2 (8%) |
| news.ycombinator.com | 1 (4%) |
Both spike days — not just one — show Email newsletter's share roughly doubling against its own baseline.
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.
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.
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.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.
tools/callThe 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):
| Stage | What actually happens |
|---|---|
| Schema context | Not 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 SQL | One 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 + execute | The 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 + audit | ekos_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. |
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.
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.
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.
$ 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\"}"}]}}