Skip to content

feat: load NEMAR's dataset tools from its MCP server - #352

Open
neuromechanist wants to merge 1 commit into
developfrom
feature/mcp-client-runtime
Open

feat: load NEMAR's dataset tools from its MCP server#352
neuromechanist wants to merge 1 commit into
developfrom
feature/mcp-client-runtime

Conversation

@neuromechanist

Copy link
Copy Markdown
Member

Replaces the NEMAR assistant's two dataset tools, which were dead, with tools
served by NEMAR's own MCP server.

search_nemar_datasets and get_nemar_dataset_details called
nemar.org/api/dataexplorer/datapipeline/.... That endpoint returns 404 --
the legacy dataexplorer site was retired and its URLs now redirect to
nemar.org/dataset/<id> -- so both tools had been non-functional. The MCP server
maps onto them almost exactly (search_datasets, describe_dataset) and adds
four more for what is inside a dataset.

Server side: nemarOrg/nemar-cli epic #1065, live at mcp.nemar.org since v0.10.1.
Anonymous, no credentials.

The two design points

Discovery runs in a worker thread, and it has to. Tools are assembled in
CommunityAssistant.__init__ -- synchronous, and the FastAPI app calls it from
inside a running event loop, where asyncio.run raises RuntimeError. There is
no correct way to await from inside a live loop on the same thread, so discovery
gets a dedicated thread with its own loop, which behaves identically whether or
not the caller has one. Invocation needs none of this: it is a plain coroutine
LangChain awaits normally.

Mutation-checked rather than asserted: replacing the worker thread with a bare
asyncio.run makes test_works_from_inside_a_running_event_loop fail while
test_works_from_a_plain_synchronous_caller still passes.

A session per call is right for this server. It is stateless by design: no
session id, GET/DELETE on the endpoint are 405, nothing to keep alive. So
connect-call-close costs one round trip and removes all lifecycle management --
reconnection, liveness, and a shared object whose failure mode is every tool
breaking at once. The module docstring says this so nobody "optimizes" it into a
persistent connection that then needs the machinery back.

Direct SDK usage rather than langchain-mcp-adapters, which pins mcp<2.0.0 and
therefore cannot negotiate the 2026-07-28 revision this server implements. The
wrapping is about sixty lines. mcp>=2.2.0 goes in the server extra, next to
langchain.

Degradation, deliberately

An unreachable server yields [] and a log line -- the same contract
_load_plugin_tools already has. An assistant that cannot start because someone
else's host is down is worse than one missing a few tools.

A tool error comes back as text, not an exception. These refusals are written
to be read: they name the cap that was exceeded, or the public URL to fetch
instead. Handing that sentence to the model lets it correct itself, where raising
would just end the turn.

Prompt

Rewritten around the six prefixed tools (nemar_search_datasets, ...) as an
explicit cost ladder, with correct nm/on dataset ids instead of OpenNeuro ds
accessions and nemar.org/dataset/{id} instead of the retired
dataexplorer/detail?dataset_id=. It also tells the model what to relay about the
streaming copy: lossy is always true, an effective_rate_hz below
source_rate_hz means the user is looking at a downsampled view,
zarr_verify_status: null means "not yet checked" rather than "wrong", and a
non-empty filled_ranges means part of a window is not real signal.

Tests: real, no mocks

tests/test_tools/test_mcp_client.py stands up a genuine MCPServer over
Streamable HTTP on a real socket and drives it with the real client through the
real LangChain wrappers. 14 tests: discovery, name prefixing, the server's own
inputSchema passed through verbatim, argument round-tripping, defaults,
multi-block text joining, refusal-as-text, schema rejection, both event-loop
cases, and three degradation paths.

tests/test_assistants/test_nemar_mcp_wiring.py covers the gap a client test
cannot see: it reads the shipped config.yaml and asserts the prompt names
the tools the loader actually produces. A prefix change or a config typo would
otherwise leave every client test green and the assistant broken.

Both files have a network-marked tier that runs against https://mcp.nemar.org
and passes: the six tool names, a real search returning real datasets, and a
refusal arriving as readable text.

Gates

uv run pytest -m "not network": 1839 passed, 21 skipped. ruff check src tests
and ruff format --check clean. mypy src/tools/mcp_client.py clean. Pre-commit
hooks pass.

One unrelated failure locally, test_version_shows_version, which asserts on
un-colored output and passes under TERM=dumb; it is a Rich-highlighting artifact
of a local TTY, touches no file in this branch, and CI has no TTY.

The NEMAR assistant's two dataset tools were dead. Both called
nemar.org/api/dataexplorer/datapipeline/..., which returns 404: the legacy
dataexplorer site was retired and its URLs now redirect to
nemar.org/dataset/<id>. So this replaces them rather than supplementing them.

Adds an MCP client runtime. Two design points worth knowing before changing it:

Discovery runs in a worker thread with its own loop, and that is not a
workaround. Tools are assembled in CommunityAssistant.__init__, which is
synchronous and which the FastAPI app calls from inside a running event loop,
where asyncio.run raises. A dedicated thread is correct whether or not the
caller has a loop. Invocation needs none of this: it is a plain coroutine.
Mutation-checked, and with a bare asyncio.run the running-loop test fails
while the plain synchronous one still passes.

A session per call is the right shape for this server, not laziness. The
NEMAR server is stateless by design: no session id, GET and DELETE on the
endpoint are 405, and there is nothing to keep alive. Connect-call-close costs
one round trip and removes every piece of lifecycle management a persistent
client would need.

This wraps the SDK directly rather than using langchain-mcp-adapters, which
pins mcp<2.0.0 and so cannot negotiate the 2026-07-28 revision the server
implements. The dependency goes in the server extra, next to langchain.

Failure degrades and never breaks: an unreachable server yields an empty tool
list and a log line, matching _load_plugin_tools' contract. A tool error comes
back as TEXT rather than raised, because the server's refusals name the cap
they hit or a public URL to read instead, and handing that sentence to the
model lets it correct itself.

The system prompt is rewritten around the six prefixed tools as a cost ladder,
with correct nm/on dataset ids and nemar.org/dataset links, plus what to relay
about the streaming copy: lossy is always true, effective_rate_hz below
source_rate_hz means a downsampled view, zarr_verify_status null means not yet
checked rather than wrong, and a non-empty filled_ranges means part of a window
is not real signal.

Tests are real, no mocks: a genuine MCPServer over Streamable HTTP on a real
socket, driven by the real client through the real LangChain wrappers. Plus a
wiring test that reads the shipped config.yaml and asserts the prompt names the
tools the loader actually produces, and a network-marked tier that runs against
https://mcp.nemar.org and passes.
@neuromechanist

Copy link
Copy Markdown
Member Author

CI is red, and none of it is this PR

Three failures, all pre-existing infrastructure. Evidence for each, since "not my
fault" deserves proof:

Test (3.11) / Test (3.12) -- one test:
test_hed_routes_mounted, AttributeError: '_IncludedRouter' object has no attribute 'path'. CI installs unlocked (uv pip install --system -e ".[dev]"), so
it resolved starlette==1.6.0 / fastapi==0.141.1, while uv.lock pins
0.50.0 / 0.128.0. Starlette 1.x puts _IncludedRouter objects in app.routes,
and those have no .path.

The app is not broken -- I checked before assuming. Under
fastapi==0.141.1 / starlette==1.6.0, /hed/ask answers 422 and
/hed/sessions answers 200, so the routes are mounted and serving; only the
test's introspection was stale. Fixed in #353, which asserts the routes by
reaching them instead, and passes under both starlette majors. This PR should go
green once that merges.

My own lockfile change adds mcp and its dependencies and moves exactly one
existing pin, idna 3.11 to 3.19. No web-framework package moved.

Build and Test Docker Image -- the Dockerfile's apt-get step installing the
GitHub CLI keyring exits 100. An apt/upstream-repo failure, unrelated to Python
dependencies.

claude-review -- the action itself errors: "Internal error: directory mismatch
for directory .../tsconfig.json".

Lint and sync-cors pass.

What this PR was verified against

Locally, on the locked dependency set: 1839 passed, 21 skipped, with only the
pre-existing test_version_shows_version color artifact (it asserts on un-colored
output and passes under TERM=dumb; CI has no TTY, and it touches no file in this
branch). ruff check src tests, ruff format --check, mypy src/tools/mcp_client.py
and the pre-commit hooks all clean.

And the part that matters most: the network-marked tier passes against the real
https://mcp.nemar.org, so the six tool names, a real search, and a
refusal-arriving-as-text are confirmed against the live server rather than only
against the in-process fixture.

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