Skip to content

semcode-mcp: answer requests concurrently - #10

Open
brauner wants to merge 1 commit into
masoncl:mainfrom
brauner:mcp-concurrent-requests
Open

brauner wants to merge 1 commit into
masoncl:mainfrom
brauner:mcp-concurrent-requests

Conversation

@brauner

@brauner brauner commented Nov 4, 2026

Copy link
Copy Markdown

The MCP server reads one request from stdin, awaits handle_request()
for it and only then reads the next one. So a query that scans the
whole database, grep_functions over every function body or
find_implementors, holds up every other client of the server until it
is done, and they all run into the idle timeout of the MCP client at
the same time. Twenty review agents on one server stalled for the
better part of an hour behind a few such scans, while the CLI, which
runs the same query code in a process of its own, kept answering in
under a second.

So hand each request to a task of its own and let the responses go
out as they finish. JSON-RPC matches them by id, the order doesn't
matter, and the stdout lock already serializes the writes with the
notification task. The handlers borrow their arguments across awaits
in a way rustc can't prove Send for, so the task drives the request on
a thread of the blocking pool with Handle::block_on() instead of
running it on a worker. That also keeps a CPU bound scan off the
runtime's workers. The number of requests in flight is capped at the
number of CPUs with a semaphore, so that any number of scans can't
thrash the machine. Notifications are still handled inline, there is
nothing to wait for there. A task that can't write its response gives
up on it and logs that, the loop ends on its own when stdin is closed.

A find_function sent right after a grep_functions over all function
bodies of a kernel tree used to be answered after the scan, 26 seconds
in. Now it comes back in 4 seconds while the scan is still running.

The MCP server reads one request from stdin, awaits handle_request()
for it and only then reads the next one. So a query that scans the
whole database, grep_functions over every function body or
find_implementors, holds up every other client of the server until it
is done, and they all run into the idle timeout of the MCP client at
the same time. Twenty review agents on one server stalled for the
better part of an hour behind a few such scans, while the CLI, which
runs the same query code in a process of its own, kept answering in
under a second.

So hand each request to a task of its own and let the responses go
out as they finish. JSON-RPC matches them by id, the order doesn't
matter, and the stdout lock already serializes the writes with the
notification task. The handlers borrow their arguments across awaits
in a way rustc can't prove Send for, so the task drives the request on
a thread of the blocking pool with Handle::block_on() instead of
running it on a worker. That also keeps a CPU bound scan off the
runtime's workers. The number of requests in flight is capped at the
number of CPUs with a semaphore, so that any number of scans can't
thrash the machine. Notifications are still handled inline, there is
nothing to wait for there. A task that can't write its response gives
up on it and logs that, the loop ends on its own when stdin is closed.

A find_function sent right after a grep_functions over all function
bodies of a kernel tree used to be answered after the scan, 26 seconds
in. Now it comes back in 4 seconds while the scan is still running.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant