{"slug": "how-mcp-toolbox-turns-agent-text-into-clickhouse-vectors", "title": "How MCP Toolbox turns agent text into ClickHouse vectors", "summary": "Google's open-source MCP Toolbox for Databases, version 1.9.0, now natively supports ClickHouse, enabling AI agents to perform semantic search by automatically converting text queries into vectors using a Gemini embedding model, eliminating the need for custom embedding code. The tool, which also supports PostgreSQL, MySQL, and other databases, allows developers to define parameterized SQL tools in YAML, with features like connection pooling and OpenTelemetry metrics.", "body_md": "If you've built an AI agent that needs semantic search, you've probably hit the same awkward gap everyone hits: your LLM speaks text, your database speaks SQL and vectors, and something in the middle has to do the translation. Usually that *something* ends up being bespoke application code or tools you write and maintain - accept a query string, call an embedding API, format the vector, splice it into SQL, hope you escaped everything correctly.\n\n[MCP Toolbox for Databases](https://github.com/googleapis/mcp-toolbox) by Google closes that gap natively, and it works with ClickHouse out of the box. You declare a Gemini embedding model in YAML, mark a tool parameter as `embeddedBy` that model, and Toolbox handles the entire `text → vector → search` pipeline transparently. The agent never sees a vector. It sends `\"how do I configure TTL on a table?\"` and gets back ranked rows.\n\nIn this post I'll cover what MCP Toolbox is, how to install and set it up, how to use its prebuilt ClickHouse tools, and then the main event: building an ingestion tool that embeds on insert and a search tool that embeds the query and ranks by cosine distance - with no embedding service of your own to write or maintain. Then we'll load a synthetic corpus through it and look at what actually comes back.\n\nWant to try it out? Give this post to your coding agent and let it follow the steps and set everything up for you.\n\nEverything below was run end to end against Toolbox **1.9.0** and a **ClickHouse Cloud 26.4.1** service, the stable releases at the time of writing.\n\n## What is MCP Toolbox?\n\nMCP Toolbox for Databases is Google's open-source (Apache 2.0) Model Context Protocol server, originally released as `genai-toolbox` before MCP existed and since renamed. It's a single Go binary that sits between AI agents and your databases, and it serves two distinct purposes:\n\n1. **A ready-to-use MCP server.** Point it at databases such as ClickHouse and Postgres with a`--prebuilt` flag and any MCP client - Claude Code, Gemini CLI, Codex, your IDE - instantly gets generic tools like`execute_sql` and`list_tables` . Great for exploration and development.\n2. **A custom tools framework.** Define curated, parameterized SQL statements in YAML and expose*those* as tools instead of raw SQL access. This is a great pattern for production as the agent can only invoke the queries you wrote, passing typed parameters that the driver escapes on its way to the database.\n\nAlongside ClickHouse it supports PostgreSQL, MySQL, SQL Server, Oracle, MongoDB, Redis, Valkey, Elasticsearch, Neo4j, Cassandra, Snowflake, Trino, CockroachDB, TiDB, and the Google Cloud fleet (AlloyDB, BigQuery, Cloud SQL, Spanner, Firestore). A single `tools.yaml` can define sources across several of them, so one MCP endpoint can expose tools for your ClickHouse analytics and a Postgres app database side by side. That isn't federation, though - each tool binds to exactly one source, so an agent correlates the two by making two calls and joining the results in its own context, not in SQL.\n\nUnder the hood you also get connection pooling, optional authenticated tool invocation, and OpenTelemetry metrics and traces for free.\n\n## Installation\n\nPick whichever fits your setup:\n\n```\n1# Homebrew (macOS / Linux)\n2brew install mcp-toolbox\n3\n4# Or grab the binary directly (see the releases page for versions/platforms)\n5export VERSION=1.9.0\n6curl -L -o toolbox https://storage.googleapis.com/mcp-toolbox-for-databases/v$VERSION/darwin/arm64/toolbox\n7chmod +x toolbox\n8\n9# Or Docker\n10docker pull us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:$VERSION\n11\n12# Or zero-install via npx (convenient, but not the fastest startup)\n13npx @toolbox-sdk/server --config tools.yaml\n```\n\nVerify with `toolbox --version`. The server listens on `127.0.0.1:5000` by default - loopback, not all interfaces, which is the right default for a process holding database credentials. Here is an example tools.yaml\n\n```\n1kind: source\n2name: my-clickhouse\n3type: clickhouse\n4host: ${CLICKHOUSE_HOST}\n5port: ${CLICKHOUSE_PORT}\n6database: ${CLICKHOUSE_DATABASE}\n7user: ${CLICKHOUSE_USER}\n8password: ${CLICKHOUSE_PASSWORD}\n9protocol: ${CLICKHOUSE_PROTOCOL}\n10secure: true\n11\n12---\n13kind: tool\n14name: execute_sql\n15type: clickhouse-execute-sql\n16source: my-clickhouse\n17description: Execute a SQL query against ClickHouse and return the rows.\n18\n19---\n20kind: tool\n21name: list_databases\n22type: clickhouse-list-databases\n23source: my-clickhouse\n24description: List all databases in ClickHouse.\n25\n26---\n27kind: tool\n28name: list_tables\n29type: clickhouse-list-tables\n30source: my-clickhouse\n31description: List the tables in a ClickHouse database.\n32\n33---\n34kind: embeddingModel\n35name: gemini-embedder\n36type: gemini\n37model: gemini-embedding-001\n38project: ${GOOGLE_CLOUD_PROJECT}\n39location: ${GOOGLE_CLOUD_LOCATION}\n40dimension: 768\n41\n42---\n43kind: tool\n44name: insert_doc\n45type: clickhouse-sql\n46source: my-clickhouse\n47description: Indexes a new document and its vector embedding.\n48statement: |\n49  INSERT INTO vectors.documents (content, embedding) VALUES (?, ?)\n50parameters:\n51  - name: content\n52    type: string\n53    description: The text content to store.\n54  - name: text_to_embed\n55    type: string\n56    description: Hidden copy of content, embedded as a vector.\n57    valueFromParam: content\n58    embeddedBy: gemini-embedder\n59\n60---\n61kind: tool\n62name: search_docs\n63type: clickhouse-sql\n64source: my-clickhouse\n65description: Finds the most semantically similar documents to a query.\n66statement: |\n67  SELECT content, cosineDistance(embedding, ?) AS distance\n68  FROM vectors.documents\n69  ORDER BY distance ASC\n70  LIMIT 5\n71parameters:\n72  - name: query\n73    type: string\n74    description: The natural-language search query.\n75    embeddedBy: gemini-embedder\n76\n77---\n78kind: toolset\n79name: semantic_search\n80tools:\n81  - insert_doc\n82  - search_docs\n83\n84---\n85kind: toolset\n86name: clickhouse_explore\n87tools:\n88  - execute_sql\n89  - list_databases\n90  - list_tables\n```\n\nAn FYI before you write any YAML: **1.9 prefers a flat config format**. Each resource is its own YAML document with `kind`, `name`, and `type` keys, separated by `---`. Older examples on the internet use a nested format (` sources:` → `my-clickhouse:` → …) where `kind` carries the type. That older shape still parses and runs fine in 1.9  (I kept a nested config around and it executed happily) so nothing is broken if you have one. `toolbox migrate` converts it when you want the new shape, and all the examples below use it.\n\n## Quick start: prebuilt ClickHouse tools\n\nYou can use Toolbox as a generic ClickHouse MCP server, similar to connecting it to [mcp-clickhouse](https://github.com/clickhouse/mcp-clickhouse) or the [ClickHouse Cloud MCP server](https://clickhouse.com/docs/products/cloud/features/ai-ml/mcp/remote-mcp). Add this to your MCP client config (e.g. `.mcp.json` for Claude Code or `claude_desktop_config.json` for Claude Desktop):\n\n```\n1{\n2  \"mcpServers\": {\n3    \"clickhouse\": {\n4      \"command\": \"npx\",\n5      \"args\": [\"-y\", \"@toolbox-sdk/server\", \"--prebuilt=clickhouse\", \"--stdio\"],\n6      \"env\": {\n7        \"CLICKHOUSE_HOST\": \"your-instance.clickhouse.cloud\",\n8        \"CLICKHOUSE_PORT\": \"8443\",\n9        \"CLICKHOUSE_USER\": \"default\",\n10        \"CLICKHOUSE_PASSWORD\": \"…\",\n11        \"CLICKHOUSE_DATABASE\": \"default\",\n12        \"CLICKHOUSE_PROTOCOL\": \"https\"\n13      }\n14    }\n15  }\n16}\n```\n\nAll six of those variables are required, and the prebuilt config fails fast and tells you exactly which one is missing, which is nicer than a connection timeout. Note that `CLICKHOUSE_HOST` is a bare hostname: scheme and port live in `CLICKHOUSE_PROTOCOL` and `CLICKHOUSE_PORT`, so `https://host:8443` in the host field won't work.\n\nThat gives your agent three tools immediately: `execute_sql`, `list_databases`, and `list_tables`. You can now ask \"what's the schema of my events table?\" and the agent figures it out.\n\nToolbox is refreshingly blunt about what this mode is for, logging a warning on every start:\n\nThese prebuilt configs are intended for 'build-time' use cases, where agents are helping trusted developers build things. They are not secure enough for 'run time' use cases, where the agent will be talking to potentially untrusted developers.\n\nWhich is the cue for the curated tools below.\n\n## Native text → vector → search\n\nClickHouse also has strong [vector search](https://clickhouse.com/blog/vector-search-clickhouse-p1) support, and includes distance functions such as cosine and support for HNSW indexes. Toolbox's custom tools framework has first-class *embedding models* as a resource type. When a tool parameter carries an `embeddedBy: <model-name>` hint, Toolbox intercepts the raw text at invocation time, batches it to the embedding model's API, and binds the resulting vector to the statement's `?` placeholder.\n\nFor ClickHouse specifically, the vector is handed to the `clickhouse-go` driver as a raw `[]float32` rather than a string you assembled yourself:\n\n```\n1// internal/embeddingmodels/embeddingmodels.go\n2// FormatVectorForClickHouse returns the raw []float32 slice, which the\n3// clickhouse-go driver binds natively to Array(Float32) parameters.\n4func FormatVectorForClickHouse(vectorFloats []float32) any {\n5    if len(vectorFloats) == 0 {\n6        return []float32{}\n7    }\n8    return vectorFloats\n9}\n```\n\nThat means your SQL can use ClickHouse's vector functions directly against a parameter placeholder. There are exactly two of these formatters in the codebase - one for pgvector, one for ClickHouse.\n\nDon’t be confused by the `?`,  it's not the same as a \"prepared statement\". I pulled the executed statement back out of `system.query_log`, and the vector arrives **inlined as a literal**:\n\n```\n1query_head: SELECT content, cosineDistance(embedding, [-0.002279004, 0.012003845, 0.0052243723, …\n2query_len:  10626\n3param_1 present in Settings: 0\n```\n\nThere is no server-side parameter binding here, no `param_*` settings, just a ten-kilobyte SQL string that `clickhouse-go` interpolated client-side. I tested some basic injection to see how this affects security, by putting `x') AS a FROM system.one WHERE 1=1 UNION ALL SELECT version(--` into a `string` parameter. It comes back as that exact text in a result column, not as executed SQL. So the guarantee is *the driver serialises typed values for you and escapes them*, which removes the injection class you'd create by hand-building literals. It just isn't a prepared statement.\n\nIt has its downsides, too. Every embedded query writes its full text, vector included, into `query_log`. A busy search tool will inflate that table considerably, and vectors are the least compressible thing you could put there.\n\n### Step 1: The ClickHouse table\n\nWe need a table to hold documents and their embeddings. A few deliberate choices here:\n\n```\n1CREATE DATABASE vectors;\n2\n3CREATE TABLE vectors.documents\n4(\n5    id        UUID DEFAULT generateUUIDv4(),\n6    content   String,\n7    embedding Array(Float32),\n8    INDEX idx_embedding embedding TYPE vector_similarity('hnsw', 'cosineDistance', 768)\n9)\n10ENGINE = MergeTree\n11ORDER BY (id);\n```\n\nNotes on the schema:\n\n- **`Array(Float32)`** is the canonical embedding column type in ClickHouse, and it's what Toolbox binds to.\n- **The `vector_similarity` skipping index** gives you approximate nearest neighbor (HNSW) search. The third argument (`768` ) is the vector dimension and**must match the `dimension` you configure on the embedding model** . On 26.4 both`allow_experimental_vector_similarity_index` and`enable_vector_similarity_index` were already`1` , so no setting change was needed; on older releases you may need to enable them yourself. For small corpora (up to a few million rows) you can skip the index entirely - brute-force`cosineDistance` over a columnar`Array(Float32)` is fast, and you can always add the index later with`ALTER TABLE ... ADD INDEX` .\n- **Pick cosine and stick to it.**`gemini-embedding-001` output at 768 dimensions is** not** unit-normalized - across my corpus the L2 norms ran 0.5788 to 0.5928, averaging 0.5862.`cosineDistance` normalizes internally so it doesn't care, but`L2Distance` will rank differently and skew with magnitude, and because the index above declares`'cosineDistance'` , an`L2Distance` query gets no index help at all. Choose one metric, declare it in the index, and use it consistently.\n- **`ORDER BY` won't accelerate the vector search itself** - ANN search goes through the skipping index. Choose your`ORDER BY` based on the metadata filters you'll combine with vector search (tenant, category, date). It's effectively immutable,`ALTER TABLE … MODIFY ORDER BY` can only append columns, not reorder or remove them, so decide before you load data. If your search tool will always filter by, say,`category` , put that first:`ORDER BY (category, id)` .\n\nOn ClickHouse Cloud you get the shared-storage engine substituted automatically, with the index carried through intact:\n\n```\n1CREATE TABLE vectors.documents\n2(\n3    `id` UUID DEFAULT generateUUIDv4(),\n4    `content` String,\n5    `embedding` Array(Float32),\n6    INDEX idx_embedding embedding TYPE vector_similarity('hnsw', 'cosineDistance', 768) GRANULARITY 100000000\n7)\n8ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}')\n9ORDER BY id\n10SETTINGS index_granularity = 8192\n```\n\n### Step 2: The Toolbox configuration\n\nEverything lives in one `tools.yaml`. Three kinds of resources: a source, an embedding model, and the tools.\n\n**The source**\n\nClickHouse Cloud connects over HTTPS on 8443; a local instance uses HTTP on 8123:\n\n```\n1kind: source\n2name: my-clickhouse\n3type: clickhouse\n4host: ${CLICKHOUSE_HOST}\n5port: ${CLICKHOUSE_PORT}\n6database: ${CLICKHOUSE_DATABASE}\n7user: ${CLICKHOUSE_USER}\n8password: ${CLICKHOUSE_PASSWORD}\n9protocol: ${CLICKHOUSE_PROTOCOL}\n10secure: true\n```\n\n**The embedding model**\n\nAs of 1.9, `gemini` is the *only* supported provider - there's a single `gemini` package under `internal/embeddingmodels`, and `openai`, `ollama`, `bedrock` and friends are all rejected at config parse time. If you need a different provider today, you're embedding outside Toolbox.\n\nThere are two ways to authenticate, and the choice is implied by which fields you set. An API key from Google AI Studio:\n\n```\n1kind: embeddingModel\n2name: gemini-embedder\n3type: gemini\n4model: gemini-embedding-001\n5apiKey: ${GOOGLE_API_KEY}\n6dimension: 768\n```\n\nOr `project` and `location` instead of `apiKey`, which switches it to the Vertex AI backend and picks up your Application Default Credentials:\n\n```\n1kind: embeddingModel\n2name: gemini-embedder\n3type: gemini\n4model: gemini-embedding-001\n5project: ${GOOGLE_CLOUD_PROJECT}\n6location: ${GOOGLE_CLOUD_LOCATION}   # e.g. us-central1\n7dimension: 768\n```\n\nWith `gcloud auth application-default login` already done, that second form works with no further setup, and the server confirms which path it took on startup:\n\n```\n1INFO \"Using Vertex AI backend for Gemini embedding\" \"my-project\" \"us-central1\"\n2INFO \"Initialized 1 embeddingModels: gemini-embedder\"\n```\n\nThe field list is short and strict: `model`, `dimension`, and then either `apiKey` or `project` + `location`. Plausible-looking extras (` useVertex`, `taskType`, `outputDimensionality`) are all rejected as unknown fields. You can see the reason for rejecting these fields in the source:\n\n```\n1// internal/embeddingmodels/gemini/gemini.go\n2embedConfig := &genai.EmbedContentConfig{\n3    TaskType: \"SEMANTIC_SIMILARITY\",\n4}\n5\n6if m.Dimension > 0 {\n7    embedConfig.OutputDimensionality = genai.Ptr(m.Dimension)\n8}\n```\n\nFirst, `dimension` maps straight onto `OutputDimensionality`, which tells you the field *instructs* the model rather than describing it. Set `dimension: 512` against the same `gemini-embedding-001` and you get a 512-element vector back. So there's no model output dimension you need to look up; the only thing `dimension` has to agree with is the width declared in your column's `vector_similarity` index. Because Toolbox has no idea what that is, a disagreement surfaces as a ClickHouse error on insert or search, not as a config parse failure. Get it right up front.\n\nSecond, and more consequentially for a semantic search post: **`taskType` is hardcoded to `SEMANTIC_SIMILARITY`**, which is why the config field is rejected. Google's own guidance for retrieval is asymmetric - embed your documents as `RETRIEVAL_DOCUMENT` and your queries as `RETRIEVAL_QUERY`, so that the two sides land in the space the model was tuned for. Toolbox gives you neither, and no override. `SEMANTIC_SIMILARITY` optimises for \"are these two texts alike?\", which is a subtly different question from \"does this document answer this query?\", and it's the same task type on both the ingest and search paths.\n\nIn practice it still works well, as the results below show. But it's a plausible contributor to the compressed distance spread you'll see there - everything landing between 0.15 and 0.30 rather than spreading out - and if you need to squeeze retrieval quality, this is the knob you'd reach for first and the one you can't currently turn. Embedding outside Toolbox is the only way to set it today.\n\n**The ingestion tool**\n\nHere's the clever bit. On insert you need the same text twice - once to store as `String`, once to embed into the vector column. Asking an LLM to repeat an identical string across two parameters is wasteful and error-prone, so Toolbox has `valueFromParam`: a hidden parameter that mirrors another parameter's value. It never appears in the tool manifest - the agent doesn't know it exists:\n\n```\n1kind: tool\n2name: insert_doc\n3type: clickhouse-sql\n4source: my-clickhouse\n5description: Indexes a new document and its vector embedding.\n6statement: |\n7  INSERT INTO vectors.documents (content, embedding) VALUES (?, ?)\n8parameters:\n9  - name: content\n10    type: string\n11    description: The text content to store.\n12  - name: text_to_embed\n13    type: string\n14    description: Hidden copy of content, embedded as a vector.\n15    valueFromParam: content\n16    embeddedBy: gemini-embedder\n```\n\nAsking the server for its tool manifest over MCP returns a single property for `insert_doc`:\n\n```\n1- execute_sql    | params: sql\n2- insert_doc     | params: content\n3- list_databases | params: none\n4- list_tables    | params: database\n5- search_docs    | params: query\n```\n\n**The search tool.**\n\nThe agent supplies plain text; Toolbox embeds it and binds the vector to the `?`:\n\n```\n1kind: tool\n2name: search_docs\n3type: clickhouse-sql\n4source: my-clickhouse\n5description: Finds the most semantically similar documents to a query.\n6statement: |\n7  SELECT content, cosineDistance(embedding, ?) AS distance\n8  FROM vectors.documents\n9  ORDER BY distance ASC\n10  LIMIT 5\n11parameters:\n12  - name: query\n13    type: string\n14    description: The natural-language search query.\n15    embeddedBy: gemini-embedder\n```\n\nOptionally, group them into a toolset so clients can load just these two:\n\n```\n1kind: toolset\n2name: semantic_search\n3tools:\n4  - insert_doc\n5  - search_docs\n```\n\nTwo constraints to remember. Only `string`-typed parameters can declare `embeddedBy`, which the config parser catches before the server will even start:\n\n```\n1ERROR \"unable to parse config file at \\\"tools.yaml\\\": document 3: error unmarshaling\n2tool \\\"dim_probe\\\": … parameter type \\\"integer\\\" cannot specify 'embeddedBy'\"\n```\n\nThe referenced embedding model must also be defined in the same configuration - but that one is *not* checked at parse time. A tool pointing at a model that doesn't exist loads happily and fails on first use, which is a much worse place to find out:\n\n```\n1ERROR \"error embedding parameters: embedding model does not exist: does-not-exist\"\n```\n\n### Step 3: Run it\n\n```\n1./toolbox --config tools.yaml   # listens on 127.0.0.1:5000\n```\n\nI usually go straight to the bundled UI at `http://127.0.0.1:5000/ui` (add `--ui` when you start the server) to eyeball what loaded. But before wiring up a client at all, you can exercise a tool straight from the shell:\n\n```\n1toolbox invoke search_docs '{\"query\":\"making queries faster\"}' \\\n2  --config tools.yaml --log-level ERROR\n```\n\nThe `--log-level ERROR` matters if you're piping into `jq`: Toolbox writes its startup log lines to stdout alongside the JSON result, so a default-level invocation produces output that isn't valid JSON. This one-liner is the fastest way to prove your embedding credentials work before you start debugging a client integration.\n\n## Loading a corpus\n\nA two-row demo doesn't tell you much about whether semantic search is working, so we can load something with enough topical spread to be falsifiable: 57 short documents across five clusters - ClickHouse internals, operations and Kubernetes, general programming, cooking, and hillwalking. Retrieval that's actually semantic should cross those cluster boundaries when the wording demands it and stay inside them when it doesn't.\n\nIngestion goes through the `insert_doc` tool itself, so Toolbox does all the embedding. Note that **the `/api/tool/<name>/invoke` REST endpoints are off by default in 1.9**, and hitting one without the flag returns `410 Gone` with an error:\n\n```\n1{\"status\":\"Gone\",\"error\":\"/api native endpoints are disabled by default. Please use the standard /mcp JSON-RPC endpoint\"}\n```\n\nStart the server with `--enable-api` and the same request returns `200` with correctly ranked results, so the REST route is available if you want the simpler client. I went with MCP JSON-RPC instead, for two reasons: it needs no extra flag, and it's the transport your agents will use anyway, so the seeding path exercises the same code as production traffic.\n\nThe whole client is about twenty lines:\n\n``` php\n1def invoke(url: str, tool: str, arguments: dict) -> dict:\n2    \"\"\"Call a tool over Toolbox's MCP JSON-RPC endpoint.\"\"\"\n3    payload = {\n4        \"jsonrpc\": \"2.0\",\n5        \"id\": 1,\n6        \"method\": \"tools/call\",\n7        \"params\": {\"name\": tool, \"arguments\": arguments},\n8    }\n9    request = urllib.request.Request(\n10        f\"{url}/mcp\",\n11        data=json.dumps(payload).encode(),\n12        headers={\n13            \"Content-Type\": \"application/json\",\n14            \"Accept\": \"application/json, text/event-stream\",\n15        },\n16    )\n17    with urllib.request.urlopen(request) as response:\n18        body = json.load(response)\n19    if \"error\" in body:\n20        raise RuntimeError(body[\"error\"][\"message\"])\n21    return body[\"result\"]\n```\n\nThe `Accept: application/json, text/event-stream` header is what the streamable-HTTP MCP transport specifies. Toolbox 1.9 happens to answer without it too, but send it anyway - it costs nothing and it's what a spec-compliant client does.\n\nThen fan the corpus out across a small thread pool:\n\n```\n1with ThreadPoolExecutor(max_workers=8) as pool:\n2    failures = [failure for failure in pool.map(insert, documents) if failure]\nbash\n1$ python3 seed_documents.py --url http://127.0.0.1:5000\n2inserted 57/57 in 4.1s\n```\n\nFour seconds for 57 documents, at concurrency 8, with an embedding round trip to Vertex AI inside every single one. Confirming what landed:\n\n```\n1SELECT count() AS docs, any(length(embedding)) AS dims\n2FROM vectors.documents\n1┌─docs─┬─dims─┐\n21. │   57 │  768 │\n3   └──────┴──────┘\n```\n\nAnd what it costs to store, which is more interesting than it sounds. Be careful which columns you ask for, though: `data_uncompressed_bytes` covers column data only, while `bytes_on_disk` includes the secondary indices, so comparing those two directly tells you nothing. Break the index out separately:\n\n```\n1SELECT sum(rows) AS docs,\n2       formatReadableSize(sum(data_uncompressed_bytes))              AS col_uncompressed,\n3       formatReadableSize(sum(data_compressed_bytes))                AS col_compressed,\n4       formatReadableSize(sum(secondary_indices_uncompressed_bytes)) AS idx_uncompressed,\n5       formatReadableSize(sum(secondary_indices_compressed_bytes))   AS idx_compressed,\n6       formatReadableSize(sum(bytes_on_disk))                        AS on_disk\n7FROM system.parts\n8WHERE database = 'vectors' AND table = 'documents' AND active\n1┌─docs─┬─col_uncompressed─┬─col_compressed─┬─idx_uncompressed─┬─idx_compressed─┬─on_disk────┐\n21. │   57 │ 178.36 KiB       │ 162.34 KiB     │ 100.77 KiB       │ 69.05 KiB      │ 231.85 KiB │\n3   └──────┴──────────────────┴────────────────┴──────────────────┴────────────────┴────────────┘\n```\n\nEmbeddings really are close to incompressible - 178.36 KiB down to 162.34 KiB is a compression ratio of **1.1x**, where log and metric columns often manage 10x or more, because high-entropy floats give the codecs nothing to work with. (That figure covers all three columns, but `embedding` is most of it; `content` is a few KiB of the total.) And the HNSW index is **29.8% of the part** at this scale, 69 KiB of the 232 KiB total, buying you nothing over a brute-force scan of 57 rows. So if you’re asking \"should I add the index?\" for a small corpus: not yet.\n\n## Searching it\n\nNow the part that either works or doesn't. Four queries, none of which share meaningful keywords with the documents they should retrieve, top three hits each with cosine distance:\n\n**\"making queries faster\"**\n\n```\n10.1653  Distributed tables fan a query out to shards and merge the partial results…\n20.1708  Dictionaries hold reference data in memory so joins become key lookups…\n30.1715  Reading fewer columns is the single biggest lever on scan performance…\n```\n\n**\"why did my container get killed\"**\n\n```\n10.1833  The OOMKilled reason on a terminated container means it exceeded its memory limit…\n20.2313  Liveness probes restart a container; readiness probes only remove it from…\n30.2345  A pod stuck in CrashLoopBackOff is usually failing its own startup…\n```\n\n**\"staying safe in bad weather on a hill\"**\n\n```\n10.1565  Pitching a tent on a slight rise keeps you dry when overnight rain pools…\n20.1667  Mountain weather can turn within an hour, so carry a shell even on a clear morning.\n30.2069  Navigating with a map and compass still works when the phone battery dies…\n```\n\nThe second one is my favourite: \"why did my container get killed\" retrieves the OOMKilled note first, and the words *killed* and *container* aside, the query shares nothing with it - no \"memory\", no \"limit\". All three hits stay inside the twelve-document operations cluster, and none of the other 45 intrude.\n\nAnd then the one that's less impressive:\n\n**\"what should I make for dinner\"**\n\n```\n10.2853  Sourdough starter needs feeding with equal parts flour and water roughly…\n20.2943  Baking is closer to chemistry than cooking, so weigh your flour rather than…\n30.3030  Distributed traces show where a request spent its time; metrics tell you…\n```\n\nThe top two are in the right cluster, but a distributed tracing note has crashed the dinner party at rank three. The whole result set sits between 0.28 and 0.30, a much flatter and more distant spread than the 0.16-0.23 of the good queries. My corpus simply contains no document that answers \"what should I make for dinner\" - it has cooking *technique*, not recipes - and a pure top-k search always returns k rows whether or not any of them deserve to be there. In production you'd apply a distance threshold (`WHERE distance < 0.25`, tuned against your own data) so the tool can return nothing rather than return noise. An agent handed three irrelevant rows will usually try to use them.\n\n## Using it: MCP clients and application SDKs\n\n### From an MCP client (Claude Code, Gemini CLI, …)\n\nPoint your client at the running server over HTTP:\n\n```\n1{\n2  \"mcpServers\": {\n3    \"clickhouse-semantic-search\": {\n4      \"type\": \"http\",\n5      \"url\": \"http://127.0.0.1:5000/mcp/semantic_search\"\n6    }\n7  }\n8}\n```\n\n`http://127.0.0.1:5000/mcp` exposes every tool in the file; appending a toolset name scopes it to that toolset.\n\nIf you'd rather not manage a long-running server, let the client own the process over stdio. This form also solves credential loading, since MCP clients don't source your shell profile:\n\n```\n1{\n2  \"mcpServers\": {\n3    \"clickhouse-toolbox\": {\n4      \"command\": \"sh\",\n5      \"args\": [\n6        \"-c\",\n7        \"set -a; . \\\"$HOME/.config/clickhouse.env\\\"; set +a; exec toolbox --config /path/to/tools.yaml --stdio\"\n8      ]\n9    }\n10  }\n11}\n```\n\nOne gotcha that cost me more time than it should have, and whose real cause is not the obvious one. I added the embedding model and vector tools to `tools.yaml`, and an already-running Toolbox process carried on serving the old three-tool manifest. `tools/list` didn't include `insert_doc`, and calling it anyway returned `invalid tool name: tool with name \"insert_doc\" does not exist` - which sends you hunting for a YAML bug that isn't there.\n\nMy first assumption was that hot reload had failed. It hadn't: reload works fine, for both an in-place append and an atomic replace, within a second or two. The actual culprit is that **`${VAR}` interpolation resolves against the running process's environment**. That server had been started *before* I added `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` to my env file, so every reload attempt hit a variable it couldn't resolve, and Toolbox - reasonably - kept the last config that worked. What makes it hard to spot is the log level:\n\n```\n1WARN \"error loading configs unable to parse config file at \\\"tools.yaml\\\":\n2error parsing environment variables: environment variable not found: \\\"GOOGLE_CLOUD_PROJECT\\\"\"\n```\n\nThat's a `WARN`, not an `ERROR`, buried in request logs, on a server you probably started in another terminal an hour ago. So: hot reload is real and you can lean on it for statement and parameter changes, but any edit that introduces a *new environment variable* needs a restart, because a running process can't see variables that didn't exist when it forked.\n\nNow the interaction looks like this from the agent's perspective:\n\n**User:** Save this note: \"ClickHouse skipping indexes store metadata about granules so queries can skip blocks that definitely don't match.\"\n\n**Agent:** *calls `insert_doc(content=\"ClickHouse skipping indexes store…\")`* - Toolbox copies the content into the hidden parameter, embeds it into a 768-dim vector, and binds both to the INSERT.\n\n**User:** What did I save about making queries faster?\n\n**Agent:** *calls `search_docs(query=\"making queries faster\")`* - Toolbox embeds the query and ClickHouse ranks by cosine distance.\n\nThe agent's tool call and the database query are both plain text and plain SQL. The vector layer is invisible.\n\n### From application code\n\nFor custom agents, Toolbox ships client SDKs for Python, JavaScript/TypeScript, Go, and Java, with framework adapters for LangChain/LangGraph, LlamaIndex, and Google's ADK. The core Python SDK:\n\n```\n1pip install toolbox-core\npython\n1import asyncio\n2from toolbox_core import ToolboxClient\n3\n4async def main():\n5    async with ToolboxClient(\"http://127.0.0.1:5000\") as client:\n6        # Load the toolset and hand the tools to your agent framework…\n7        tools = await client.load_toolset(\"semantic_search\")\n8\n9        # …or invoke a tool directly. The string is embedded server-side;\n10        # your application never touches a vector.\n11        search = await client.load_tool(\"search_docs\")\n12        results = await search(query=\"how do I make queries faster?\")\n13        print(results)\n14\n15asyncio.run(main())\n```\n\nOr drop the tools straight into a LangGraph agent:\n\n``` python\n1from toolbox_langchain import ToolboxClient\n2from langgraph.prebuilt import create_react_agent\n3\n4async with ToolboxClient(\"http://127.0.0.1:5000\") as client:\n5    tools = client.load_toolset(\"semantic_search\")\n6    agent = create_react_agent(model, tools)\n```\n\nEither way, the embedding step stays server-side in Toolbox - swap `gemini-embedding-001` for another model or change the dimension, and no application code changes.\n\n## Production notes\n\nA few things worth knowing before you take this past a demo:\n\n- **Batching.** If a single invocation has multiple embedded parameters using the same model, Toolbox batches them into one embedding API call. But each*tool invocation* still embeds inline - my 57 documents meant 57 separate round trips to Vertex AI, which is fine at 4.1 seconds and very much not fine at a million rows. For bulk ingestion, embed offline and load with a proper batch pipeline (tens of thousands of rows per`INSERT` , or`async_insert=1` if you must trickle). Save`insert_doc` for agent-driven, one-at-a-time writes.\n- **Return nothing rather than noise.** As the dinner query showed, top-k always returns k rows. Add a distance threshold to search tools that an agent will act on.\n- **Driver-escaped parameters, not prepared statements.** Regular`parameters` are serialised and escaped by`clickhouse-go` from typed values, which removes the injection class - but the statement reaches the server as one interpolated string, vector literal and all, so don't describe this as server-side binding and do expect fat`query_log` entries. Toolbox also supports`templateParameters` for things like table names, and those are plain string substitution with no escaping at all, so keep anything user-controlled in regular parameters.\n- **Exact vs. approximate.**`cosineDistance` with no index is exact and O(n); the`vector_similarity` HNSW index makes it approximate and fast. At 57 rows the index is 29.8% of the part and earns nothing, so brute force is the whole story at this scale. Test recall on your own data before flipping to ANN.\n- **You can't set the embedding task type.**`SEMANTIC_SIMILARITY` is hardcoded, so the asymmetric`RETRIEVAL_DOCUMENT` /`RETRIEVAL_QUERY` pairing Google recommends for search isn't available. If retrieval quality is the thing you're optimising, embed outside Toolbox.\n- **Observability and auth.** Toolbox emits OpenTelemetry traces (including the embedding call) and metrics out of the box, and tools can require authenticated invocation - worth wiring up before exposing write tools like`insert_doc` .\n- **Bind address and origins.** The default listener is loopback-only, but`--allowed-origins` and`--allowed-hosts` both default to`*` , and Toolbox warns about DNS-rebinding risk on every start. Set them explicitly.\n\n## Wrapping up\n\nThe pattern here generalizes well beyond a toy documents table: any ClickHouse table with an `Array(Float32)` column becomes agent-searchable with about forty lines of YAML - just a declarative statement of *which parameters mean text* and *which model turns them into vectors*. And the same `embeddedBy` machinery drives pgvector on Postgres, the only other engine with a vector formatter in the codebase.\n\nThe rough edges are mostly in the plumbing rather than the idea - Gemini is the only embedding provider, the REST endpoints need an opt-in flag, and a config that gains a new environment variable needs a restart rather than a reload. The one that isn't plumbing is the hardcoded `SEMANTIC_SIMILARITY` task type, which puts a ceiling on retrieval quality. Given ClickHouse's brute-force vector performance on columnar data and its maturing HNSW index, this combination - MCP Toolbox for the agent boundary, ClickHouse for storage and ranking - is still one of the fastest paths I've seen from \"we have text in a table\" to \"our agent can search it semantically.\"\n\n*MCP Toolbox is open source (Apache 2.0) at [github.com/googleapis/mcp-toolbox](https://github.com/googleapis/mcp-toolbox). Full docs at [mcp-toolbox.dev](https://mcp-toolbox.dev/documentation/introduction/), including the [ClickHouse integration](https://mcp-toolbox.dev/integrations/clickhouse/source) and [embedding models reference](https://mcp-toolbox.dev/documentation/configuration/embedding-models/). Tested against Toolbox 1.9.0 and ClickHouse Cloud 26.4.1.*", "url": "https://wpnews.pro/news/how-mcp-toolbox-turns-agent-text-into-clickhouse-vectors", "canonical_source": "https://clickhouse.com/blog/mcp-toolbox-clickhouse-vectors", "published_at": "2026-09-07 09:00:00+00:00", "updated_at": "2026-09-07 13:55:31.752219+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure", "developer-tools"], "entities": ["Google", "MCP Toolbox for Databases", "ClickHouse", "Gemini", "ClickHouse Cloud"], "alternates": {"html": "https://wpnews.pro/news/how-mcp-toolbox-turns-agent-text-into-clickhouse-vectors", "markdown": "https://wpnews.pro/news/how-mcp-toolbox-turns-agent-text-into-clickhouse-vectors.md", "text": "https://wpnews.pro/news/how-mcp-toolbox-turns-agent-text-into-clickhouse-vectors.txt", "jsonld": "https://wpnews.pro/news/how-mcp-toolbox-turns-agent-text-into-clickhouse-vectors.jsonld"}}