Skip to content

M72 · An MCP server for Qdrant that is a binary rather than an interpreter - #156

Merged
tonytonycoder11 merged 1 commit into
mainfrom
feat/m72-mcp-server
Sep 10, 2026
Merged

M72 · An MCP server for Qdrant that is a binary rather than an interpreter#156
tonytonycoder11 merged 1 commit into
mainfrom
feat/m72-mcp-server

Conversation

@tonytonycoder11

Copy link
Copy Markdown
Contributor

Closes #125.

The question this was gated on

The item said to settle one thing before writing any of it: whether the Kotlin MCP SDK compiles for the
targets kdrant-cli ships for. It does. io.modelcontextprotocol:kotlin-sdk 0.15.0 publishes native
artifacts for linuxX64, linuxArm64, macosArm64 and mingwX64, and so do the core and server
modules underneath it. macosX64 is the one gap, and it is the only place this module reaches less far
than the CLI.

So the argument in the description survives: the only MCP server for Qdrant is written in Python, and for a
process an agent spawns and kills repeatedly, install footprint and cold start are most of what
distinguishes one server from another. Measured: 7.9 MB on macOS arm64, 9.1 MB on Windows x64, 16.4 MB on
Linux x64 and 15.3 MB on Linux arm64, the Linux ones larger because they link libcurl and its TLS stack
statically.

What it exposes

Six read tools on by default: list_collections, describe_collection, search_points, scroll_points,
retrieve_points, count_points. upsert_points and delete_points are off unless the operator passes
--allow-writes, and a read-only server does not register them at all, so they are absent from
tools/list rather than present and refused. Nothing creates or drops a collection: a model that can
create collections can fill a disk, and the operations that change what a deployment is belong in
kdrant-cli, where a person is typing.

Three things found by running it rather than compiling it

Stdout is the protocol's channel, and the SDK's logging library prints to it. It announces itself with
a kotlin-logging: initializing... banner before the first JSON-RPC frame, so a client sees a parse error
on the handshake and disconnects. That is a server that works in a terminal and is broken everywhere it is
used. The binary reconfigures the logger at startup and sends diagnostics to stderr, where the MCP
convention puts them.

Point ids came back as Num(value=1). That is what the data class prints, and it is not an id: a model
cannot hand it to retrieve_points. A numeric id is a number now and a uuid is a string, because Qdrant
treats 1 and "1" as different points.

The refusal message for a disabled write tool is unreachable over MCP. The SDK rejects an unregistered
name before any handler runs, so a model calling upsert_points on a read-only server gets "not found",
which is the truth. What tells it why is the instructions field at initialize, which says the server is
read-only. The guard in Tools.call stays for a caller holding it directly, and the KDoc says where each
fires.

Two build notes worth reading before touching the module

read and write take a size_t, which is 64-bit on Linux and macOS and 32-bit on mingw, and the
compiler refuses a declaration in a shared native source set whose signature varies by platform. So the two
primitives live in posixMain and mingwX64Main and everything that is logic stays in nativeMain.

Declaring one dependsOn turns the default hierarchy template off entirely. That left nativeMain
attached to nothing and the entry point compiled into no target at all, and the link failed with "could not
find main", which is a long way from the cause. The hierarchy is wired by hand now, with a comment saying
why.

Verification

prove-mcp.sh is the exit criterion, and it ran locally against a real
Qdrant 1.19.1 before this was committed: a client initializes, lists the tools, describes a collection,
completes a search and checks that the point aligned with the query ranks first, confirms a write is
refused read-only and offered with --allow-writes, and asserts that every line on stdout is JSON-RPC.
It runs on every push and again at every release, for each platform the release attaches a binary for.

The tool surface is tested without a transport in ToolsTest, because the schemas, the write gating and the
argument messages are what a model meets and none of them need a process.

…reter

The way a model reaches a tool is MCP, and the only MCP server for Qdrant is
written in Python, so every agent that searches a collection does it through an
interpreter, a virtual environment and a dependency tree. For a process an agent
spawns and kills repeatedly, install footprint and cold start are not incidental
properties; they are most of what distinguishes one server from another, and a
JVM server would have competed with the Python one on nothing.

The gating question this item said to settle first is settled: the Kotlin MCP SDK
publishes native artifacts for linuxX64, linuxArm64, macosArm64 and mingwX64,
which is every target the release attaches a binary for. macosX64 is the one gap,
and it is the only place this module reaches less far than the CLI.

Six tools read and are on by default. upsert_points and delete_points exist and
are off unless the operator passes --allow-writes, because letting a model write
to somebody's index is a different risk class from letting it read one, and
shipping writes on by default would be that choice made for the operator rather
than by them. A read-only server does not register them at all, so they are absent
from tools/list rather than present and refused. Nothing creates or drops a
collection: a model that can create collections can fill a disk, and the
operations that change what a deployment is belong where a person is typing.

Three things were found by running it rather than by compiling it.

Stdout is the protocol's channel and the SDK's logging library prints to it,
starting with a banner before the first frame, so a client saw a parse error on
the handshake and disconnected. The binary reconfigures that at startup and sends
its diagnostics to stderr, where MCP expects them.

Point ids came out as `Num(value=1)`, which is what the data class prints and not
something a model can hand back to retrieve_points. A numeric id is a number now
and a uuid is a string, because Qdrant treats 1 and "1" as different points.

And the refusal message for a disabled write tool is unreachable over MCP: the SDK
rejects an unregistered name before any handler runs. What tells the model why is
the instructions field, which says the server is read-only. The guard stays for a
caller holding the tools directly, and the KDoc now says where each fires.

The hierarchy is wired by hand because it had to be. `read` and `write` take a
size_t, 64-bit on Linux and macOS and 32-bit on mingw, and the compiler refuses a
declaration in a shared native source set whose signature varies by platform. So
the two primitives live one level down and everything that is logic stays shared.
Declaring one dependsOn turns the default hierarchy template off entirely, which
left nativeMain attached to nothing and the entry point compiled into no target:
the link failed with "could not find main", a long way from the cause.

prove-mcp.sh is the exit criterion, run on every push and again at every release
for each platform: a client initializes, lists the tools, describes a collection,
completes a search and checks the top hit, confirms a write is refused read-only
and offered with the flag, and asserts that every line on stdout is JSON-RPC.
@tonytonycoder11
tonytonycoder11 requested a review from a team as a code owner September 10, 2026 19:37
@trueup-by-nacode-studios

Copy link
Copy Markdown

This pull request adds no capability. The code it touches can already touch the filesystem (2 places).

What I could not follow

39 calls resolve to a name whose type is written nowhere, so what they reach is not established here, and 2 more are in tests and benchmarks, which this report does not judge. Across everything it read that is 41 of 594, 7%. That is too many to list, and where they are is more useful than which they are:

  • kdrant-mcp/src/commonMain/kotlin/dev/kdrant/mcp/Tools.kt — 36 calls
  • kdrant-mcp/src/nativeMain/kotlin/dev/kdrant/mcp/Main.kt — 2 calls
  • kdrant-mcp/src/commonMain/kotlin/dev/kdrant/mcp/Server.kt — 1 calls

If one of those reaches the network or starts a process, this report does not show it.

17 files read · 9226 ms · against ec2b125 · engine 1.4.1

@tonytonycoder11 tonytonycoder11 moved this to In progress in Kdrant Sep 10, 2026
@tonytonycoder11
tonytonycoder11 merged commit 8f1690b into main Sep 10, 2026
14 checks passed
@tonytonycoder11
tonytonycoder11 deleted the feat/m72-mcp-server branch September 10, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

M72 · An agent cannot reach Qdrant through anything written in Kotlin

1 participant