Skip to the content

Portfolio project · Adrián Vázquez

Qdrant's whole API as 56 MCP tools. Every claim tested against a real server.

A Model Context Protocol server in Python that lets an LLM manage a Qdrant vector database. Its engineering log is public: what went wrong, how it was found, and the test that now stands guard.

  • 56

    MCP tools

    31 of them read-only

  • 554

    tests

    unit and integration, as pytest collects them

  • 17

    changelog entries

    latest: v2.6.0

  • 3,322

    lines of Python

    3,322 of source, 5,717 of tests

  • 46

    KB of tool schemas

    default toolset, loaded into the model’s context

Every figure here that looks like a measurement is generated from the repository by a script that CI re-runs. Hover one to see which.

01 · The method

Nothing is written down until it has been run.

Qdrant's behavior is easy to assume and expensive to get wrong. Every claim in this project's tool descriptions goes through the same three steps.

  1. 01 / 03

    Probe

    Run the scenario against a real Qdrant server, the failure path first. Read the state afterwards, not just the response.

    A valid alias operation followed by a failing one, then list the aliases: the first is still applied. So the tool does not say "atomic".

  2. 02 / 03

    Pin

    Turn what was observed into an integration test, so a future Qdrant release that changes it fails CI instead of silently making the documentation stale.

    test_alias_batch_is_not_rolled_back_when_a_later_operation_fails

  3. 03 / 03

    Review

    An independent reader with no knowledge of the reasoning is told to reproduce, not speculate. Every finding is reproduced a second time before anyone acts on it.

    One such review found that an empty filter deleted a whole collection. It is in the log.

Read the whole log

02 · Architecture

How a tool call travels.

Five hops between a model and the database. Each one is a decision, not a default.

  1. It receives 56 tool definitions. The advertised schemas are compact — 46 KB for the default toolset instead of about 150 KB — because they are loaded into the model’s context on every conversation.

    tests/unit/test_schemas.py
  2. stdio by default. The remote transport refuses to start without a shared secret, and there is no hosted instance: this is a project you run, not a service.

    src/mcp_qdrant/cli.py
  3. One choke point. It filters toolsets, removes every mutating tool in read-only mode (31 of 56 survive) and caps results per call. A tool cannot bypass it by registering itself differently.

    src/mcp_qdrant/tools/registry.py
  4. Transport failures are retried with backoff. A Qdrant error reaches the model as Qdrant’s own message, never as a generic "Error executing tool".

    src/mcp_qdrant/tools/errors.py
  5. The official SDK, so tool inputs reuse its models and track its version. The server never generates embeddings: you bring your vectors.

    ROADMAP.md · principle 1

03 · In action

Four things a model can do with it.

These are not mock-ups. Every call below was executed through the MCP server against a real Qdrant, and the response is what came back.

04 · Tools

Every tool, from the live registry.

56 tools in 6 toolsets. Only core (13) is on by default; the rest is opt-in, so a client is not handed everything at once.

Showing 13 of 56

core ● on by default
Collection and point CRUD, qdrant_query and a health check. A complete server on its own.
search
Batched and grouped queries, recommend, discover, distance matrices.
payload
Payload editing and indexing, named vectors, ordered batch operations.
snapshots
Backup and restore, per collection and for the whole storage.
admin
Aliases, shard layout, optimizer progress, collection copy, payload profiling.
observability
Telemetry, metrics, quotas, self-diagnosed issues.

Try it in two minutes.

Pick the toolsets and copy the config. It runs locally; there is nothing to sign up for.

Toolsets

Registered tools 13

.mcp.json

{
  "mcpServers": {
    "qdrant": {
      "command": "uvx",
      "args": [
        "mcp-qdrant"
      ],
      "env": {
        "QDRANT_URL": "http://localhost:6333",
        "QDRANT_MCP_TOOLSETS": "core"
      }
    }
  }
}
  • uvx mcp-qdrant · Claude Code or any MCP client: uvx runs it without installing
  • Claude Desktop: download mcp-qdrant.mcpb from the latest release and double-click it
  • Docker, for streamable-http. streamable-http refuses to start without a shared secret. The image only makes sense in this mode.
    docker run --rm -p 8000:8000 \
      -e QDRANT_URL=http://host.docker.internal:6333 \
      -e QDRANT_MCP_TRANSPORT=streamable-http \
      -e QDRANT_MCP_HTTP_HOST=0.0.0.0 \
      -e QDRANT_MCP_SHARED_SECRET=<a long random secret> \
      ghcr.io/avaazquezz/qdrant-mcp:latest

05 · The log

The engineering log.

What went wrong, how it was found, and the test that now stands guard. Each entry links the pull request and the release that fixed it.

  1. 01 / 09

    Hybrid search that could not run

    What was claimed
    Hybrid search over dense and sparse vectors works: the query tool’s own description carried an example.
    What was run
    Run that example against a real Qdrant, then try to write a point with a sparse vector through the upsert tool.
    What actually happens
    The example failed ("Conversion between sparse and regular vectors failed"), and the upsert tool rejected every vector that was not a plain list of numbers. The flagship feature could not work, and no integration test covered sparse vectors at all.
    What changed
    One shared vector type for writing and querying, conditional upserts (insert-only, update-only), and integration tests for named, sparse and multivector points.
  2. 02 / 09

    The alias batch that was not atomic

    What was claimed
    Alias operations sent in one call are applied atomically (the first draft of the tool description).
    What was run
    One request with a valid "create alias" followed by a "create alias" on a collection that does not exist, then list the aliases.
    What actually happens
    The request fails, but the first alias is still there. A batch is not rolled back.
    What changed
    The description says so and recommends a single operation to re-point an alias, which is all-or-nothing by definition. An integration test fails if Qdrant ever changes this.
  3. 03 / 09

    Diversity that did nothing

    What was claimed
    MMR re-ranks results for diversity.
    What was run
    Query with MMR, no candidate limit and a limit of 2, and compare it with the plain query.
    What actually happens
    Identical results. The candidate pool defaults to the limit, so MMR has nothing to choose from. And the offset is applied inside those results: offset 1 with limit 2 returns one point.
    What changed
    Both facts are in the tool description and pinned by a test.
  4. 04 / 09

    Tool definitions that ate the context

    What was claimed
    A compact set of tool definitions.
    What was run
    Measure the input schemas that a client loads into the model’s context.
    What actually happens
    571 KB for the whole catalog and 150 KB for the default toolset: every tool carried its own copy of Qdrant’s 16 KB filter schema.
    What changed
    The advertised schemas were compacted by roughly two thirds without changing what is accepted, and a test now enforces a size budget so the weight cannot creep back.
  5. 05 / 09found by an independent review

    A compact schema that refused legal calls

    What was claimed
    The compact filter schema accepts exactly what Qdrant’s filter model accepts.
    What was run
    Validate the advertised schema and the real model against the same valid and invalid inputs.
    What actually happens
    The first draft refused a legal single condition where a list was expected. After parity tests were added, an independent review found it also refused explicit nulls and a legal "global" option. A client validating arguments against the schema would have rejected legal calls.
    What changed
    Both fixed. The parity tests now run the schema and the model against the same samples, and a second test fails if the installed client gains a condition the hand-written grammar does not mention.
  6. 06 / 09found by an independent review

    The filter that deleted everything

    What was claimed
    A delete needs exactly one selector, ids or a filter, so it cannot be ambiguous.
    What was run
    Delete with the filters {}, {"must": []} and {"should": []} on a small collection.
    What actually happens
    Each one deleted every point. An empty filter matches everything, and nothing in the tool said no. A model building its condition list dynamically and ending up with an empty one would wipe the data.
    What changed
    A filter with no conditions is refused in delete, payload and vector tools and inside batch updates, and a batch with one refused operation sends nothing at all.
  7. 07 / 09found by an independent review

    Atomic, since the third phase, and never true

    What was claimed
    The batch-update tool runs its operations atomically (in its description since the third phase).
    What was run
    A valid upsert followed by one with the wrong vector size, then count the points.
    What actually happens
    The first one stays applied. It was never atomic. This is the same mistake as the alias entry: a property of Qdrant asserted without running it.
    What changed
    The description, the tool title, the README and this page no longer say it, and a test pins the real behavior.
  8. 08 / 09found by an independent review

    A filter that silently did not filter

    What was claimed
    A prefetch stage takes a filter.
    What was run
    Give a stage Qdrant’s own key, "filter", instead of the tool’s "query_filter".
    What actually happens
    No error, and the results were not filtered: unknown keys were silently ignored. A misspelled option of MMR ran with its defaults the same way, and a typo in a point’s payload key dropped the payload.
    What changed
    Nested inputs now reject unknown keys with a clear error.
  9. 09 / 09found by an independent review

    Keys that could not be pasted into a filter

    What was claimed
    Payload profiling reports keys written the way Qdrant’s filters expect.
    What was run
    Profile a collection with a key that contains a space, then use the printed key in a filter.
    What actually happens
    Qdrant answers 400: such a key needs double quotes. A literal key "k[]" was read as an array path, and an index created on a quoted key never matched its entry in the profile.
    What changed
    Keys are quoted where the syntax requires it, which is also the form Qdrant reports for indexes, so keys now match their index.

06 · Quality

What keeps it honest.

None of this is promised on the page. It is enforced by the repository.

  1. 01

    push

  2. 02

    lint · types · generators

  3. 03

    tests · real Qdrant

  4. 04

    merge

  5. 05

    tag

  6. 06

    PyPI · GHCR · .mcpb

Continuous integration

Every pull request runs lint, formatting, strict type checking and the whole test suite, with the integration tests against a real Qdrant 1.19.0 service. Three generators (the README table, the data behind this page, the demo transcripts) fail the build if they drift.

Releases

A tag publishes to PyPI with Trusted Publishing (no stored token), pushes the Docker image to GHCR and attaches the .mcpb bundle to the release. Three workflows, checked after every tag.

Roadmap

Each phase has its own branch and pull request. ROADMAP.md keeps the decisions and the verified findings, including the features that were discarded and why.

07 · Questions

Questions a reviewer asks.

Is this affiliated with Qdrant?

No. It is an independent, MIT-licensed project that wraps Qdrant’s own qdrant-client SDK.

Why does it not create embeddings?

It is a wrapper, not a RAG system, on purpose: the first principle in the roadmap. It never embeds, parses or chunks anything; the client brings its vectors. That keeps the server small and makes every tool a faithful reflection of one Qdrant operation.

Is there a hosted version?

No, by design. An earlier phase added a multi-tenant public mode and it was removed again: this is a project you run (uvx mcp-qdrant), not a service someone has to operate.

How was it built?

Python 3.12 with uv, on top of the official MCP Python SDK and qdrant-client, with Pydantic validating every input and tenacity retrying transport failures. Quality is enforced by pytest against a real Qdrant in CI, strict mypy, ruff and pre-commit; GitHub Actions publishes to PyPI (Trusted Publishing), to GHCR and as a Claude Desktop bundle. Claude Code assisted the work and the commits carry its Co-Authored-By trailer; what decides what is true is the toolchain, not the assistant. This page is Nuxt, Tailwind, GSAP and Lenis, served by nginx in Docker.

Which Qdrant versions does it support?

v1.19.0 and newer are the only versions it is tested against. Two tools need an endpoint that older servers answer with 404.

What does it not cover?

Distributed-cluster administration: shard keys and resharding need a cluster, and the project targets a single node. The roadmap records that decision and what was verified.

08 · Contact

See the code.

The repository has the whole history: every phase, every pull request, every finding.