"""
``cellucid mcp``: a local MCP server that lets an agent app on this computer
-- Claude Code, Claude Desktop, Cursor -- work in a Cellucid viewer.
It speaks MCP over stdio (the official ``mcp`` SDK: ``pip install
'cellucid[mcp]'``) and drives a viewer the cellucid Python or R package
serves on this computer, with no relay and no deployed site in between:
- ``cellucid mcp --open data.h5ad`` serves the file and opens its viewer in
the browser itself, and stops it when the app ends the server;
- ``cellucid mcp`` drives a viewer opened elsewhere -- in a notebook
(``cellucid.show_anndata(adata)``), a script (``cellucid.AnnDataViewer``)
or R (``cellucid::cellucid_view(obj)``) -- found among those published on
this computer (:mod:`cellucid._agent_link`); ``--viewer`` names one.
The tools, their descriptions and the instructions are the web app's (the
web connector's tools, generated into ``mcp_tools.json``); the viewer page
runs each call as the app's agent ("Claude Code (local)") under the
person's permission settings: a change runs or waits for the person's
answer in the viewer's confirm card -- asked in the app too, as an MCP
elicitation, when the app can ask -- or is refused with one sentence. Every
step is in the person's history, and the person can Stop the agent's turn or
undo it whole.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import sys
import threading
import webbrowser
from pathlib import Path
from typing import TYPE_CHECKING, Any
from ._agent_link import STOPPED, CallListener, ViewerLink, ViewerUnavailableError
from ._mcp_core import SERVER_NAME, SERVER_TITLE, LocalAgentSession, mcp_tools_document
if TYPE_CHECKING:
from .jupyter import AnnDataViewer
logger = logging.getLogger("cellucid.mcp")
#: What the person is asked in the agent's app: allow (accept) or deny (decline).
_ELICIT_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}}
SDK_MISSING = (
"cellucid mcp needs the MCP SDK, which this environment lacks. Install it with: "
"pip install 'cellucid[mcp]'"
)
def _import_sdk() -> tuple[Any, Any, Any]:
try:
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.stdio import stdio_server
except ImportError as error:
raise RuntimeError(SDK_MISSING) from error
return Server, NotificationOptions, stdio_server
def _client_supports_elicitation(ctx: Any) -> bool:
"""Whether the app takes a form elicitation: it declared ``elicitation``,
and not for URLs alone (an empty declaration means forms, as before
modes existed)."""
with contextlib.suppress(Exception):
capabilities = ctx.session.client_capabilities
elicitation = None if capabilities is None else getattr(capabilities, "elicitation", None)
if elicitation is None:
return False
form = getattr(elicitation, "form", None)
url = getattr(elicitation, "url", None)
return form is not None or url is None
return False
def _adopt_client(session: LocalAgentSession, ctx: Any) -> None:
with contextlib.suppress(Exception):
params = ctx.session.client_params
info = None if params is None else params.client_info
if info is not None:
session.adopt_client(info.name, getattr(info, "title", None))
async def _ask_in_app(ctx: Any, session: LocalAgentSession, link: Any, request_id: str, message: str) -> None:
"""The person's question, asked in the agent's app too (an MCP form
elicitation): Accept allows, Decline denies; dismissing it leaves the
viewer's card to answer. The model never answers it: the app shows it."""
logger.debug("call %s: asking in the app", request_id)
try:
answer = await ctx.session.elicit_form(
f"{message} (You can also answer in the Cellucid viewer.)",
_ELICIT_SCHEMA,
related_request_id=ctx.request_id,
)
except asyncio.CancelledError:
raise
except Exception as error: # an app that cannot ask after all: the card asks alone
logger.debug("The app could not ask: %s", error)
return
action = getattr(answer, "action", None)
logger.debug("call %s: the app answered %s", request_id, action)
if action in {"accept", "decline"}:
await asyncio.to_thread(link.answer, request_id, action == "accept")
async def _progress(ctx: Any, step: int, message: str) -> None:
"""Tell an app that asked for progress what the call waits on (``step``
grows with every notification of the call, as progress must)."""
meta = ctx.meta if isinstance(ctx.meta, dict) else {}
token = meta.get("progress_token")
if token is None:
return
with contextlib.suppress(Exception):
await ctx.session.send_progress_notification(token, step, None, message, related_request_id=ctx.request_id)
class _ViewerCall:
"""One call sent to the viewer, stopped there whenever the app gives up on
it -- also while it is still being sent.
The send runs in a worker thread, and a cancelled task stops waiting for
it at once while the thread goes on: the page may already run the call
when the app's ``notifications/cancelled`` arrives, before the link that
sent it is back. Whichever of the two ends second -- the send, or the
app giving up -- sends ``agent_cancel``, so a cancel is never lost to how
the two interleave (``tests/test_mcp.py`` holds a send until the server
took the cancel, which lost it before).
"""
def __init__(self, session: LocalAgentSession, request_id: str) -> None:
self._session = session
self.request_id = request_id
self._lock = threading.Lock()
self._link: ViewerLink | None = None
self._abandoned = False
def send(self, tool: str, args: dict[str, Any], listener: CallListener) -> ViewerLink:
"""Send the call (in a worker thread); stop it at once when the app gave up meanwhile."""
link = self._session.start_call(self.request_id, tool, args, listener)
with self._lock:
self._link = link
abandoned = self._abandoned
if abandoned:
self._cancel(link)
return link
def abandon(self) -> None:
"""The app gave up on the call: stop it in the viewer once it is sent
(a change rolled back, a question withdrawn). From a thread of its
own -- a cancelled task may not await anything more -- that the
process waits for when it ends, so a cancel sent as the app quits
still reaches the viewer."""
with self._lock:
self._abandoned = True
link = self._link
if link is not None:
threading.Thread(target=self._cancel, args=(link,), name="cellucid-mcp-cancel").start()
def _cancel(self, link: ViewerLink) -> None:
try:
link.cancel(self.request_id)
except Exception as error: # the viewer went meanwhile: nothing runs there to stop
logger.debug("call %s: not stopped in the viewer: %s", self.request_id, error)
def _build_server(session: LocalAgentSession, holder: dict[str, Any]) -> tuple[Any, Any]:
Server, NotificationOptions, _stdio = _import_sdk()
from . import __version__
async def list_tools(ctx: Any, params: Any) -> dict[str, Any]:
_adopt_client(session, ctx)
holder.setdefault("session", ctx.session)
return {"tools": await asyncio.to_thread(session.tools)}
async def call_tool(ctx: Any, params: Any) -> dict[str, Any]:
_adopt_client(session, ctx)
holder.setdefault("session", ctx.session)
name = params.name
args = dict(params.arguments or {})
if name == mcp_tools_document()["statusTool"]["name"]:
return await asyncio.to_thread(session.status_result)
loop = asyncio.get_running_loop()
events: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue()
def listener(kind: str, data: dict[str, Any]) -> None:
loop.call_soon_threadsafe(events.put_nowait, (kind, data))
call = _ViewerCall(session, session.next_request_id())
asking: asyncio.Task[None] | None = None
progress = 0
try:
try:
link = await asyncio.to_thread(call.send, name, args, listener)
except (ViewerUnavailableError, TypeError, ValueError) as error:
# No viewer can take it, or the call is not one a viewer reads
# (a tool name no list gives): one sentence, as a tool error.
return session.error_result(str(error))
while True:
kind, data = await events.get()
logger.debug("call %s (%s): %s", call.request_id, name, kind)
if kind == "asking":
progress += 1
await _progress(ctx, progress, data["message"])
if asking is None and _client_supports_elicitation(ctx):
asking = asyncio.create_task(_ask_in_app(ctx, session, link, call.request_id, data["message"]))
elif kind == "answered":
if asking is not None:
asking.cancel()
asking = None
elif kind == "result":
return dict(data["result"])
elif kind == "failed":
# The page refused it, or went before it answered: the sentence says which.
return session.error_result(str(data["reason"]))
else:
return session.error_result(STOPPED.format(reason=data["reason"]))
except asyncio.CancelledError:
# The app gave up on the call, while it was being sent or after.
call.abandon()
raise
finally:
if asking is not None:
asking.cancel()
server = Server(
SERVER_NAME,
version=__version__,
title=SERVER_TITLE,
instructions=session.instructions,
on_list_tools=list_tools,
on_call_tool=call_tool,
)
return server, NotificationOptions(tools_changed=True)
def _open_viewer(path: str, *, port: int | None, open_browser: bool, web_source_url: str | None, web_cache_dir: str | None) -> AnnDataViewer:
"""Serve ``path`` in a viewer of this process (``--open``) and open its page."""
from . import jupyter
options: dict[str, Any] = {}
if web_source_url is not None:
options["web_source_url"] = web_source_url
if web_cache_dir is not None:
options["web_cache_dir"] = web_cache_dir
# Started once the MCP transport holds stdout (it sends what anything
# else writes there to stderr), so nothing it prints reaches the channel.
viewer = jupyter.AnnDataViewer(str(Path(path).expanduser()), port=port, auto_open=False, **options)
print(f"Cellucid viewer: {viewer.viewer_url}", file=sys.stderr, flush=True)
if open_browser and webbrowser.open(viewer.viewer_url) is not True:
print("Open the address above in a browser: no browser could be opened from here.", file=sys.stderr, flush=True)
return viewer
[docs]
def run_mcp(
open: str | Path | None = None, # noqa: A002 - the command's own word
*,
viewer: str | None = None,
port: int | None = None,
open_browser: bool = True,
web_source_url: str | None = None,
web_cache_dir: str | Path | None = None,
) -> None:
"""Run ``cellucid mcp`` on this process's stdin and stdout until the app
closes them.
Args:
open: A file or folder to serve and open in a viewer of this process
(any format Cellucid reads); its page opens in the browser, and
the viewer stops when the server ends. None drives a viewer
opened elsewhere on this computer.
viewer: Which published viewer to drive (its viewer id, dataset id or
dataset name); None takes the most recently opened one whose page
is open. Ignored with ``open``.
port: The port of the viewer ``open`` starts (None: 8765, or the next
free one).
open_browser: Whether ``open`` opens the viewer's page in the browser
(False: its address is printed on stderr).
web_source_url: Where the viewer's page is loaded from (default:
https://www.cellucid.com).
web_cache_dir: Where the viewer keeps its copy of the page.
"""
_server_class, _options_class, stdio_server = _import_sdk()
if open is not None and viewer is not None:
raise ValueError("Give --open or --viewer, not both: --open drives the viewer it opens.")
session = LocalAgentSession(choice=viewer)
holder: dict[str, Any] = {}
opened: list[AnnDataViewer] = []
server, notification_options = _build_server(session, holder)
async def main() -> None:
loop = asyncio.get_running_loop()
def tools_changed() -> None:
mcp_session = holder.get("session")
if mcp_session is None:
return
async def send() -> None:
with contextlib.suppress(Exception):
await mcp_session.send_tool_list_changed()
loop.call_soon_threadsafe(lambda: asyncio.ensure_future(send()))
session.add_tools_listener(tools_changed)
async with stdio_server() as (read_stream, write_stream):
if open is not None:
target = str(open)
session.set_opened(None, opening=f"The Cellucid viewer of {target} is still opening; try again in a moment.")
def start() -> None:
try:
started = _open_viewer(
target,
port=port,
open_browser=open_browser,
web_source_url=web_source_url,
web_cache_dir=None if web_cache_dir is None else str(web_cache_dir),
)
except Exception as error: # said to the agent and on stderr; the server stays up
message = f"The Cellucid viewer of {target} could not open: {error}"
print(message, file=sys.stderr, flush=True)
session.set_opened(None, opening=message)
return
opened.append(started)
session.set_opened(started._agent_port)
threading.Thread(target=start, name="cellucid-mcp-open", daemon=True).start()
elif await asyncio.to_thread(session.link) is None:
print(f"cellucid mcp: {session.status()['advice']} The server waits; its tools work once one is open.", file=sys.stderr, flush=True)
await server.run(read_stream, write_stream, server.create_initialization_options(notification_options))
try:
asyncio.run(main())
finally:
session.close()
for started in opened:
with contextlib.suppress(Exception), contextlib.redirect_stdout(sys.stderr):
started.stop()