Upload 14 files
Browse files- Dockerfile +14 -9
- README.md +54 -4
- app.py +34 -33
- port.json +1 -5
- requirements.txt +6 -23
- run_docker.ps1 +5 -24
- run_docker.sh +6 -73
- scanpy/mcp_output/README_MCP.md +52 -109
- scanpy/mcp_output/mcp_plugin/adapter.py +178 -211
- scanpy/mcp_output/mcp_plugin/main.py +5 -10
- scanpy/mcp_output/mcp_plugin/mcp_service.py +271 -189
- scanpy/mcp_output/requirements.txt +5 -24
- scanpy/mcp_output/start_mcp.py +26 -18
Dockerfile
CHANGED
|
@@ -1,17 +1,22 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
ENV
|
|
|
|
| 6 |
|
| 7 |
WORKDIR /app
|
| 8 |
|
| 9 |
-
|
| 10 |
-
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 11 |
|
| 12 |
-
COPY
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
EXPOSE 7860
|
| 17 |
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
ENV MCP_TRANSPORT=http
|
| 6 |
+
ENV MCP_PORT=7860
|
| 7 |
|
| 8 |
WORKDIR /app
|
| 9 |
|
| 10 |
+
RUN useradd -m -u 1000 appuser
|
|
|
|
| 11 |
|
| 12 |
+
COPY requirements.txt /app/requirements.txt
|
| 13 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY scanpy /app/scanpy
|
| 16 |
+
COPY app.py /app/app.py
|
| 17 |
+
|
| 18 |
+
RUN chown -R appuser:appuser /app
|
| 19 |
+
USER appuser
|
| 20 |
|
| 21 |
EXPOSE 7860
|
| 22 |
|
README.md
CHANGED
|
@@ -1,10 +1,60 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: scanpy MCP Service
|
| 3 |
+
emoji: 🔧
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# scanpy MCP Service
|
| 12 |
+
|
| 13 |
+
这是一个面向 `scanpy` 的 MCP 服务部署包,支持:
|
| 14 |
+
|
| 15 |
+
- 本地 `stdio`(Claude Desktop / CLI)
|
| 16 |
+
- Docker / HuggingFace Spaces 的 `http` 传输
|
| 17 |
+
|
| 18 |
+
## 可用工具
|
| 19 |
+
|
| 20 |
+
- `scanpy_health`
|
| 21 |
+
- `create_synthetic_adata`
|
| 22 |
+
- `load_h5ad`
|
| 23 |
+
- `preprocess_basic`
|
| 24 |
+
- `run_pca_neighbors_umap`
|
| 25 |
+
- `run_leiden`
|
| 26 |
+
- `rank_marker_genes`
|
| 27 |
+
- `adapter_call_function`
|
| 28 |
+
|
| 29 |
+
详见 `scanpy/mcp_output/README_MCP.md`。
|
| 30 |
+
|
| 31 |
+
## 本地 stdio 连接
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
cd scanpy/mcp_output
|
| 35 |
+
python mcp_plugin/main.py
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
或:
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
MCP_TRANSPORT=stdio python scanpy/mcp_output/start_mcp.py
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## HTTP 客户端连接
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
MCP_TRANSPORT=http MCP_PORT=8000 python scanpy/mcp_output/start_mcp.py
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
客户端连接地址:`http://localhost:8000/mcp`
|
| 51 |
+
|
| 52 |
+
## Docker / HF Spaces
|
| 53 |
+
|
| 54 |
+
该部署默认使用 `7860` 端口并直接启动 FastMCP HTTP 服务:
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
python scanpy/mcp_output/start_mcp.py
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
在 HuggingFace Spaces 上,客户端连接:`https://<your-space-host>/mcp`。
|
app.py
CHANGED
|
@@ -1,45 +1,46 @@
|
|
| 1 |
-
from
|
|
|
|
| 2 |
import os
|
| 3 |
import sys
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
app = FastAPI(
|
| 9 |
-
title="Scanpy MCP Service",
|
| 10 |
-
description="Auto-generated MCP service for scanpy",
|
| 11 |
-
version="1.0.0"
|
| 12 |
-
)
|
| 13 |
|
| 14 |
@app.get("/")
|
| 15 |
-
def root():
|
| 16 |
return {
|
| 17 |
-
"
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
}
|
| 22 |
|
|
|
|
| 23 |
@app.get("/health")
|
| 24 |
-
def
|
| 25 |
-
return {"status": "healthy"
|
|
|
|
| 26 |
|
| 27 |
@app.get("/tools")
|
| 28 |
-
def
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
if __name__ == "__main__":
|
| 43 |
-
import uvicorn
|
| 44 |
-
port = int(os.environ.get("PORT", 7860))
|
| 45 |
-
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
import os
|
| 4 |
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI
|
| 9 |
|
| 10 |
+
PLUGIN_DIR = Path(__file__).resolve().parent / "scanpy" / "mcp_output" / "mcp_plugin"
|
| 11 |
+
plugin_path = str(PLUGIN_DIR)
|
| 12 |
+
if plugin_path not in sys.path:
|
| 13 |
+
sys.path.insert(0, plugin_path)
|
| 14 |
+
|
| 15 |
+
app = FastAPI(title="scanpy-mcp-info", version="1.0.0")
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
@app.get("/")
|
| 19 |
+
def root() -> dict[str, Any]:
|
| 20 |
return {
|
| 21 |
+
"name": "scanpy MCP service",
|
| 22 |
+
"description": "Supplementary info API for local development",
|
| 23 |
+
"mcp_entrypoint": "scanpy/mcp_output/start_mcp.py",
|
| 24 |
+
"default_port": int(os.getenv("PORT", "7860")),
|
| 25 |
}
|
| 26 |
|
| 27 |
+
|
| 28 |
@app.get("/health")
|
| 29 |
+
def health() -> dict[str, str]:
|
| 30 |
+
return {"status": "healthy"}
|
| 31 |
+
|
| 32 |
|
| 33 |
@app.get("/tools")
|
| 34 |
+
def tools() -> dict[str, Any]:
|
| 35 |
+
from mcp_service import create_app
|
| 36 |
+
|
| 37 |
+
mcp = create_app()
|
| 38 |
+
tool_items = []
|
| 39 |
+
for tool in getattr(mcp, "tools", []):
|
| 40 |
+
tool_items.append(
|
| 41 |
+
{
|
| 42 |
+
"name": getattr(tool, "name", "unknown"),
|
| 43 |
+
"description": getattr(tool, "description", ""),
|
| 44 |
+
}
|
| 45 |
+
)
|
| 46 |
+
return {"tools": tool_items, "count": len(tool_items)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
port.json
CHANGED
|
@@ -1,5 +1 @@
|
|
| 1 |
-
{
|
| 2 |
-
"repo": "scanpy",
|
| 3 |
-
"port": 7918,
|
| 4 |
-
"timestamp": 1773467942
|
| 5 |
-
}
|
|
|
|
| 1 |
+
{"port": 7860}
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -1,25 +1,8 @@
|
|
| 1 |
fastmcp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
fastapi
|
| 3 |
-
uvicorn
|
| 4 |
-
pydantic>=2.0.0
|
| 5 |
-
anndata>=0.10.8
|
| 6 |
-
fast-array-utils[accel,sparse]>=1.2.1
|
| 7 |
-
h5py>=3.11
|
| 8 |
-
joblib
|
| 9 |
-
matplotlib>=3.9
|
| 10 |
-
natsort
|
| 11 |
-
networkx>=2.8.8
|
| 12 |
-
numba>=0.60
|
| 13 |
-
numpy>=2
|
| 14 |
-
packaging>=25
|
| 15 |
-
pandas>=2.2.2
|
| 16 |
-
patsy
|
| 17 |
-
pynndescent>=0.5.13
|
| 18 |
-
scikit-learn>=1.4.2
|
| 19 |
-
scipy>=1.13
|
| 20 |
-
seaborn>=0.13.2
|
| 21 |
-
session-info2
|
| 22 |
-
statsmodels>=0.14.5
|
| 23 |
-
tqdm
|
| 24 |
-
typing-extensions; python_version<'3.13'
|
| 25 |
-
umap-learn>=0.5.7
|
|
|
|
| 1 |
fastmcp
|
| 2 |
+
scanpy
|
| 3 |
+
anndata
|
| 4 |
+
numpy
|
| 5 |
+
pandas
|
| 6 |
+
scipy
|
| 7 |
fastapi
|
| 8 |
+
uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
run_docker.ps1
CHANGED
|
@@ -1,26 +1,7 @@
|
|
| 1 |
-
cd $PSScriptRoot
|
| 2 |
$ErrorActionPreference = "Stop"
|
| 3 |
-
|
| 4 |
-
$
|
| 5 |
-
$imageName =
|
| 6 |
-
|
| 7 |
-
$mcpPath = Join-Path $mcpDir "mcp.json"
|
| 8 |
-
if (!(Test-Path $mcpDir)) { New-Item -ItemType Directory -Path $mcpDir | Out-Null }
|
| 9 |
-
$config = @{}
|
| 10 |
-
if (Test-Path $mcpPath) {
|
| 11 |
-
try { $config = Get-Content $mcpPath -Raw | ConvertFrom-Json } catch { $config = @{} }
|
| 12 |
-
}
|
| 13 |
-
$serversOrdered = [ordered]@{}
|
| 14 |
-
if ($config -and ($config.PSObject.Properties.Name -contains "mcpServers") -and $config.mcpServers) {
|
| 15 |
-
$existing = $config.mcpServers
|
| 16 |
-
if ($existing -is [pscustomobject]) {
|
| 17 |
-
foreach ($p in $existing.PSObject.Properties) { if ($p.Name -ne $entryName) { $serversOrdered[$p.Name] = $p.Value } }
|
| 18 |
-
} elseif ($existing -is [System.Collections.IDictionary]) {
|
| 19 |
-
foreach ($k in $existing.Keys) { if ($k -ne $entryName) { $serversOrdered[$k] = $existing[$k] } }
|
| 20 |
-
}
|
| 21 |
-
}
|
| 22 |
-
$serversOrdered[$entryName] = @{ url = $entryUrl }
|
| 23 |
-
$config = @{ mcpServers = $serversOrdered }
|
| 24 |
-
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $mcpPath -Encoding UTF8
|
| 25 |
docker build -t $imageName .
|
| 26 |
-
docker run --rm -p
|
|
|
|
|
|
|
| 1 |
$ErrorActionPreference = "Stop"
|
| 2 |
+
|
| 3 |
+
$port = (Get-Content -Raw -Path "port.json" | ConvertFrom-Json).port
|
| 4 |
+
$imageName = "scanpy-mcp"
|
| 5 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
docker build -t $imageName .
|
| 7 |
+
docker run --rm -it -p "${port}:${port}" $imageName
|
run_docker.sh
CHANGED
|
@@ -1,75 +1,8 @@
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
set -euo pipefail
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
if command -v python3 >/dev/null 2>&1; then
|
| 10 |
-
python3 - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY'
|
| 11 |
-
import json, os, sys
|
| 12 |
-
path, name, url = sys.argv[1:4]
|
| 13 |
-
cfg = {"mcpServers": {}}
|
| 14 |
-
if os.path.exists(path):
|
| 15 |
-
try:
|
| 16 |
-
with open(path, "r", encoding="utf-8") as f:
|
| 17 |
-
cfg = json.load(f)
|
| 18 |
-
except Exception:
|
| 19 |
-
cfg = {"mcpServers": {}}
|
| 20 |
-
if not isinstance(cfg, dict):
|
| 21 |
-
cfg = {"mcpServers": {}}
|
| 22 |
-
servers = cfg.get("mcpServers")
|
| 23 |
-
if not isinstance(servers, dict):
|
| 24 |
-
servers = {}
|
| 25 |
-
ordered = {}
|
| 26 |
-
for k, v in servers.items():
|
| 27 |
-
if k != name:
|
| 28 |
-
ordered[k] = v
|
| 29 |
-
ordered[name] = {"url": url}
|
| 30 |
-
cfg = {"mcpServers": ordered}
|
| 31 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 32 |
-
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
| 33 |
-
PY
|
| 34 |
-
elif command -v python >/dev/null 2>&1; then
|
| 35 |
-
python - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY'
|
| 36 |
-
import json, os, sys
|
| 37 |
-
path, name, url = sys.argv[1:4]
|
| 38 |
-
cfg = {"mcpServers": {}}
|
| 39 |
-
if os.path.exists(path):
|
| 40 |
-
try:
|
| 41 |
-
with open(path, "r", encoding="utf-8") as f:
|
| 42 |
-
cfg = json.load(f)
|
| 43 |
-
except Exception:
|
| 44 |
-
cfg = {"mcpServers": {}}
|
| 45 |
-
if not isinstance(cfg, dict):
|
| 46 |
-
cfg = {"mcpServers": {}}
|
| 47 |
-
servers = cfg.get("mcpServers")
|
| 48 |
-
if not isinstance(servers, dict):
|
| 49 |
-
servers = {}
|
| 50 |
-
ordered = {}
|
| 51 |
-
for k, v in servers.items():
|
| 52 |
-
if k != name:
|
| 53 |
-
ordered[k] = v
|
| 54 |
-
ordered[name] = {"url": url}
|
| 55 |
-
cfg = {"mcpServers": ordered}
|
| 56 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 57 |
-
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
| 58 |
-
PY
|
| 59 |
-
elif command -v jq >/dev/null 2>&1; then
|
| 60 |
-
name="${mcp_entry_name}"; url="${mcp_entry_url}"
|
| 61 |
-
if [ -f "${mcp_path}" ]; then
|
| 62 |
-
tmp="$(mktemp)"
|
| 63 |
-
jq --arg name "$name" --arg url "$url" '
|
| 64 |
-
.mcpServers = (.mcpServers // {})
|
| 65 |
-
| .mcpServers as $s
|
| 66 |
-
| ($s | with_entries(select(.key != $name))) as $base
|
| 67 |
-
| .mcpServers = ($base + {($name): {"url": $url}})
|
| 68 |
-
' "${mcp_path}" > "${tmp}" && mv "${tmp}" "${mcp_path}"
|
| 69 |
-
else
|
| 70 |
-
printf '{ "mcpServers": { "%s": { "url": "%s" } } }
|
| 71 |
-
' "$name" "$url" > "${mcp_path}"
|
| 72 |
-
fi
|
| 73 |
-
fi
|
| 74 |
-
docker build -t scanpy-mcp .
|
| 75 |
-
docker run --rm -p 7918:7860 scanpy-mcp
|
|
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
PORT=$(python3 -c 'import json;print(json.load(open("port.json"))["port"])')
|
| 5 |
+
IMAGE_NAME="scanpy-mcp"
|
| 6 |
+
|
| 7 |
+
docker build -t "${IMAGE_NAME}" .
|
| 8 |
+
docker run --rm -it -p "${PORT}:${PORT}" "${IMAGE_NAME}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scanpy/mcp_output/README_MCP.md
CHANGED
|
@@ -1,130 +1,73 @@
|
|
| 1 |
-
# Scanpy MCP
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
It is designed for developer workflows that need programmatic access to:
|
| 7 |
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
-
|
| 12 |
-
- Dataset loading and tabular extraction utilities
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
-
|
| 25 |
-
-
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
-
|
| 31 |
-
-
|
| 32 |
-
- statsmodels, seaborn
|
| 33 |
-
- harmonypy, bbknn, scanorama, magic-impute, palantir, phate, phenograph
|
| 34 |
|
| 35 |
-
##
|
| 36 |
-
- Install Scanpy:
|
| 37 |
-
- `pip install scanpy`
|
| 38 |
-
- For richer workflows, also install optional ecosystem packages as needed.
|
| 39 |
-
- If deploying as an MCP (Model Context Protocol) service, include Scanpy and your MCP runtime in the same environment.
|
| 40 |
|
| 41 |
-
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
|
| 47 |
-
|
| 48 |
-
2. Run preprocessing (`filter_cells`, `filter_genes`, `calculate_qc_metrics`, `normalize_total`, `log1p`, `highly_variable_genes`, `pca`, `neighbors`)
|
| 49 |
-
3. Run analysis (`leiden`/`louvain`, `umap`, `rank_genes_groups`, `paga`)
|
| 50 |
-
4. Return tabular results (`obs_df`, `var_df`, `rank_genes_groups_df`) and/or plotting artifacts
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
- `write` — Persist processed AnnData objects
|
| 65 |
|
| 66 |
-
|
| 67 |
-
- `pbmc3k`, `pbmc68k_reduced`, `blobs`, `krumsiek11`, `toggleswitch`, `ebi_expression_atlas` — Quick demo/test datasets
|
| 68 |
-
|
| 69 |
-
### Preprocessing (`pp`)
|
| 70 |
-
- `calculate_qc_metrics` — Per-cell/per-gene QC metrics
|
| 71 |
-
- `filter_cells`, `filter_genes` — Basic filtering
|
| 72 |
-
- `normalize_total`, `log1p` — Library-size normalization and transform
|
| 73 |
-
- `highly_variable_genes` — Feature selection
|
| 74 |
-
- `scale`, `regress_out` — Feature scaling and regression
|
| 75 |
-
- `pca`, `neighbors` — Dimensionality reduction and graph construction
|
| 76 |
-
- `scrublet`, `subsample` — Doublet detection and sampling helpers
|
| 77 |
-
|
| 78 |
-
### Tools (`tl`)
|
| 79 |
-
- `leiden`, `louvain` — Graph clustering
|
| 80 |
-
- `umap`, `tsne`, `diffmap`, `draw_graph` — Embedding
|
| 81 |
-
- `paga`, `dendrogram`, `embedding_density`, `ingest` — Graph/trajectory and transfer utilities
|
| 82 |
-
- `rank_genes_groups`, `score_genes`, `marker_gene_overlap` — Marker and scoring analysis
|
| 83 |
-
|
| 84 |
-
### Plotting (`pl`)
|
| 85 |
-
- `umap`, `scatter`, `spatial` — Embedding/spatial visualization
|
| 86 |
-
- `dotplot`, `matrixplot`, `stacked_violin`, `violin`, `heatmap` — Expression summary plots
|
| 87 |
-
- `paga`, `rank_genes_groups` — Result-oriented visual outputs
|
| 88 |
-
|
| 89 |
-
### Get/Tabular Accessors
|
| 90 |
-
- `obs_df`, `var_df`, `rank_genes_groups_df` — Dataframe outputs for downstream systems
|
| 91 |
-
|
| 92 |
-
### Metrics
|
| 93 |
-
- `morans_i`, `gearys_c` — Spatial/autocorrelation metrics
|
| 94 |
-
|
| 95 |
-
### External Integrations (`external`)
|
| 96 |
-
- `bbknn`, `harmony_integrate`, `scanorama_integrate`, `magic`, `mnn_correct` — Optional integrations requiring extra packages
|
| 97 |
-
|
| 98 |
-
### CLI
|
| 99 |
-
- `scanpy`
|
| 100 |
-
- `python -m scanpy`
|
| 101 |
-
|
| 102 |
-
---
|
| 103 |
-
|
| 104 |
-
## 5) Common Issues and Notes
|
| 105 |
-
|
| 106 |
-
- **Missing optional dependencies**: many advanced endpoints require extra installs (e.g., `leidenalg`, `igraph`, `umap-learn`).
|
| 107 |
-
- **Version compatibility**: keep `scanpy`, `anndata`, and numeric stack versions aligned.
|
| 108 |
-
- **Memory/performance**: large datasets can be expensive for PCA/neighbors/UMAP; consider subsampling, sparse matrices, and staged processing.
|
| 109 |
-
- **Headless environments**: plotting may require non-interactive matplotlib backend.
|
| 110 |
-
- **External methods**: wrappers in `scanpy.external` fail gracefully only if dependency checks are handled in service code.
|
| 111 |
-
- **Data format assumptions**: most endpoints expect valid AnnData structure (`.obs`, `.var`, `.X`, optional `.raw`, `.obsm`, `.uns`).
|
| 112 |
-
|
| 113 |
-
---
|
| 114 |
-
|
| 115 |
-
## 6) Reference Links and Documentation
|
| 116 |
-
|
| 117 |
-
- Scanpy repository: https://github.com/scverse/scanpy
|
| 118 |
-
- Scanpy docs index: `docs/index.md` in repository
|
| 119 |
-
- API docs sections:
|
| 120 |
-
- `docs/api/preprocessing.md`
|
| 121 |
-
- `docs/api/tools.md`
|
| 122 |
-
- `docs/api/plotting.md`
|
| 123 |
-
- `docs/api/io.md`
|
| 124 |
-
- `docs/api/datasets.md`
|
| 125 |
-
- `docs/api/get.md`
|
| 126 |
-
- `docs/api/metrics.md`
|
| 127 |
-
- Developer docs:
|
| 128 |
-
- `docs/dev/getting-set-up.md`
|
| 129 |
-
- `docs/dev/testing.md`
|
| 130 |
-
- `docs/dev/documentation.md`
|
|
|
|
| 1 |
+
# Scanpy MCP 插件说明
|
| 2 |
|
| 3 |
+
该目录提供基于 FastMCP 的 Scanpy 工具封装,支持本地 `stdio` 与 HTTP 传输。
|
| 4 |
|
| 5 |
+
## 已暴露工具
|
|
|
|
| 6 |
|
| 7 |
+
1. `scanpy_health()`
|
| 8 |
+
- 参数:无
|
| 9 |
+
- 作用:返回服务状态、已加载模块、已缓存数据集
|
| 10 |
+
- 示例:`scanpy_health()`
|
|
|
|
| 11 |
|
| 12 |
+
2. `create_synthetic_adata(dataset_key="demo", n_obs=200, n_vars=50, seed=0)`
|
| 13 |
+
- 作用:创建内存中的合成 AnnData
|
| 14 |
+
- 示例:`create_synthetic_adata(dataset_key="toy", n_obs=300, n_vars=100, seed=42)`
|
| 15 |
|
| 16 |
+
3. `load_h5ad(file_path, dataset_key="adata")`
|
| 17 |
+
- 作用:加载本地 `.h5ad` 到内存注册表
|
| 18 |
+
- 示例:`load_h5ad(file_path="/data/sample.h5ad", dataset_key="pbmc")`
|
| 19 |
|
| 20 |
+
4. `preprocess_basic(dataset_key, target_sum=10000.0, apply_log1p=True, n_top_genes=2000, scale_data=False, max_value=10.0)`
|
| 21 |
+
- 作用:执行标准预处理流程
|
| 22 |
+
- 示例:`preprocess_basic(dataset_key="pbmc", n_top_genes=1500, scale_data=True)`
|
| 23 |
|
| 24 |
+
5. `run_pca_neighbors_umap(dataset_key, n_pcs=50, n_neighbors=15, min_dist=0.5, random_state=0)`
|
| 25 |
+
- 作用:执行 PCA + 邻居图 + UMAP
|
| 26 |
+
- 示例:`run_pca_neighbors_umap(dataset_key="pbmc", n_pcs=30, n_neighbors=10)`
|
| 27 |
|
| 28 |
+
6. `run_leiden(dataset_key, resolution=1.0, key_added="leiden", random_state=0)`
|
| 29 |
+
- 作用:执行 Leiden 聚类
|
| 30 |
+
- 示例:`run_leiden(dataset_key="pbmc", resolution=0.8)`
|
| 31 |
|
| 32 |
+
7. `rank_marker_genes(dataset_key, groupby="leiden", method="wilcoxon", n_genes=10)`
|
| 33 |
+
- 作用:按分组计算 marker genes
|
| 34 |
+
- 示例:`rank_marker_genes(dataset_key="pbmc", n_genes=20)`
|
| 35 |
|
| 36 |
+
8. `adapter_call_function(module_name, function_name, args_json="[]", kwargs_json="{}")`
|
| 37 |
+
- 作用:通过适配器调用任意可导入函数
|
| 38 |
+
- 示例:`adapter_call_function(module_name="scanpy.pp", function_name="log1p", args_json='[null]', kwargs_json='{}')`
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
## 返回格式
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
+
所有 MCP 工具统一返回:
|
| 43 |
|
| 44 |
+
```json
|
| 45 |
+
{
|
| 46 |
+
"success": true,
|
| 47 |
+
"result": {},
|
| 48 |
+
"error": null
|
| 49 |
+
}
|
| 50 |
+
```
|
| 51 |
|
| 52 |
+
失败时 `success=false`,并在 `error` 字段返回错误信息。
|
| 53 |
|
| 54 |
+
## 本地 stdio 运行
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
```bash
|
| 57 |
+
cd scanpy/mcp_output
|
| 58 |
+
python mcp_plugin/main.py
|
| 59 |
+
```
|
| 60 |
|
| 61 |
+
或:
|
| 62 |
|
| 63 |
+
```bash
|
| 64 |
+
MCP_TRANSPORT=stdio python start_mcp.py
|
| 65 |
+
```
|
| 66 |
|
| 67 |
+
## HTTP 运行
|
| 68 |
|
| 69 |
+
```bash
|
| 70 |
+
MCP_TRANSPORT=http MCP_PORT=8000 python start_mcp.py
|
| 71 |
+
```
|
|
|
|
| 72 |
|
| 73 |
+
HTTP 模式下,MCP 端点由 FastMCP 直接提供,客户端连接 `http://localhost:8000/mcp`。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scanpy/mcp_output/mcp_plugin/adapter.py
CHANGED
|
@@ -1,222 +1,189 @@
|
|
| 1 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import sys
|
| 3 |
-
from
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
sys.path.insert(0, source_path)
|
| 10 |
|
| 11 |
|
| 12 |
class Adapter:
|
| 13 |
-
""
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
self._import_error: Optional[str] = None
|
| 24 |
-
self._initialize_modules()
|
| 25 |
-
|
| 26 |
-
# ---------------------------------------------------------------------
|
| 27 |
-
# Internal helpers
|
| 28 |
-
# ---------------------------------------------------------------------
|
| 29 |
-
def _ok(self, data: Optional[Dict[str, Any]] = None, message: str = "success") -> Dict[str, Any]:
|
| 30 |
-
payload = {"status": "success", "mode": self.mode, "message": message}
|
| 31 |
-
if data:
|
| 32 |
-
payload.update(data)
|
| 33 |
-
return payload
|
| 34 |
-
|
| 35 |
-
def _error(self, message: str, guidance: Optional[str] = None, exc: Optional[Exception] = None) -> Dict[str, Any]:
|
| 36 |
-
payload = {"status": "error", "mode": self.mode, "message": message}
|
| 37 |
-
if guidance:
|
| 38 |
-
payload["guidance"] = guidance
|
| 39 |
-
if exc is not None:
|
| 40 |
-
payload["error"] = str(exc)
|
| 41 |
-
return payload
|
| 42 |
-
|
| 43 |
-
def _initialize_modules(self) -> None:
|
| 44 |
try:
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
def _safe_call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
| 60 |
try:
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
except Exception as exc:
|
| 64 |
-
return
|
| 65 |
-
"
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
# ---------------------------------------------------------------------
|
| 71 |
-
# Status and environment
|
| 72 |
-
# ---------------------------------------------------------------------
|
| 73 |
-
def health_check(self) -> Dict[str, Any]:
|
| 74 |
-
"""
|
| 75 |
-
Report adapter readiness and import state.
|
| 76 |
-
|
| 77 |
-
Returns:
|
| 78 |
-
dict: Unified response with status, mode, and module availability.
|
| 79 |
-
"""
|
| 80 |
-
available = {k: bool(v) for k, v in self._modules.items()}
|
| 81 |
-
if self.mode == "import":
|
| 82 |
-
return self._ok({"modules": available}, "Adapter is ready in import mode.")
|
| 83 |
-
return self._error(
|
| 84 |
-
"Adapter is running in fallback mode due to import failure.",
|
| 85 |
-
guidance=f"Verify repository path and dependencies. Import error: {self._import_error}",
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
# ---------------------------------------------------------------------
|
| 89 |
-
# Core scanpy package access
|
| 90 |
-
# ---------------------------------------------------------------------
|
| 91 |
-
def instance_scanpy(self) -> Dict[str, Any]:
|
| 92 |
-
"""
|
| 93 |
-
Return the imported scanpy module instance.
|
| 94 |
-
|
| 95 |
-
Returns:
|
| 96 |
-
dict: Unified response containing module object when available.
|
| 97 |
-
"""
|
| 98 |
-
mod = self._get_module("scanpy")
|
| 99 |
-
if mod is None:
|
| 100 |
-
return self._error(
|
| 101 |
-
"scanpy module is unavailable in current mode.",
|
| 102 |
-
guidance="Install required dependencies and ensure source/src is importable.",
|
| 103 |
-
)
|
| 104 |
-
return self._ok({"module": mod}, "scanpy module loaded.")
|
| 105 |
-
|
| 106 |
-
def call_scanpy_attr(self, attr_name: str, *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
| 107 |
-
"""
|
| 108 |
-
Call a top-level attribute from source.src.scanpy dynamically.
|
| 109 |
-
|
| 110 |
-
Parameters:
|
| 111 |
-
attr_name: Name of function/attribute on the scanpy package.
|
| 112 |
-
*args: Positional arguments for callable attributes.
|
| 113 |
-
**kwargs: Keyword arguments for callable attributes.
|
| 114 |
-
|
| 115 |
-
Returns:
|
| 116 |
-
dict: Unified response with call result or attribute value.
|
| 117 |
-
"""
|
| 118 |
-
mod = self._get_module("scanpy")
|
| 119 |
-
if mod is None:
|
| 120 |
-
return self._error(
|
| 121 |
-
"scanpy module is not imported.",
|
| 122 |
-
guidance="Use health_check() and resolve import issues first.",
|
| 123 |
-
)
|
| 124 |
-
if not hasattr(mod, attr_name):
|
| 125 |
-
return self._error(
|
| 126 |
-
f"Attribute '{attr_name}' not found in scanpy module.",
|
| 127 |
-
guidance="Check the attribute name against source.src.scanpy exports.",
|
| 128 |
-
)
|
| 129 |
-
target = getattr(mod, attr_name)
|
| 130 |
-
if callable(target):
|
| 131 |
-
return self._safe_call(target, *args, **kwargs)
|
| 132 |
-
return self._ok({"result": target}, f"Attribute '{attr_name}' retrieved.")
|
| 133 |
-
|
| 134 |
-
# ---------------------------------------------------------------------
|
| 135 |
-
# CLI module wrappers (source.src.scanpy.cli)
|
| 136 |
-
# ---------------------------------------------------------------------
|
| 137 |
-
def instance_cli(self) -> Dict[str, Any]:
|
| 138 |
-
"""
|
| 139 |
-
Return the imported CLI module instance.
|
| 140 |
-
|
| 141 |
-
Returns:
|
| 142 |
-
dict: Unified response containing CLI module object.
|
| 143 |
-
"""
|
| 144 |
-
mod = self._get_module("cli")
|
| 145 |
-
if mod is None:
|
| 146 |
-
return self._error(
|
| 147 |
-
"CLI module is unavailable.",
|
| 148 |
-
guidance="Ensure source.src.scanpy.cli can be imported.",
|
| 149 |
-
)
|
| 150 |
-
return self._ok({"module": mod}, "CLI module loaded.")
|
| 151 |
-
|
| 152 |
-
def call_cli_main(self, args: Optional[list] = None) -> Dict[str, Any]:
|
| 153 |
-
"""
|
| 154 |
-
Invoke CLI entry behavior when available.
|
| 155 |
-
|
| 156 |
-
Parameters:
|
| 157 |
-
args: Optional argument list to emulate command-line input.
|
| 158 |
-
|
| 159 |
-
Returns:
|
| 160 |
-
dict: Unified response with CLI execution result.
|
| 161 |
-
"""
|
| 162 |
-
mod = self._get_module("cli")
|
| 163 |
-
if mod is None:
|
| 164 |
-
return self._error(
|
| 165 |
-
"CLI module is not available in fallback context.",
|
| 166 |
-
guidance="Run 'python -m scanpy --help' in environment with dependencies.",
|
| 167 |
-
)
|
| 168 |
-
for candidate in ("main", "cli", "run"):
|
| 169 |
-
if hasattr(mod, candidate) and callable(getattr(mod, candidate)):
|
| 170 |
-
fn = getattr(mod, candidate)
|
| 171 |
-
if args is None:
|
| 172 |
-
return self._safe_call(fn)
|
| 173 |
-
return self._safe_call(fn, args)
|
| 174 |
-
return self._error(
|
| 175 |
-
"No callable CLI entry function found in source.src.scanpy.cli.",
|
| 176 |
-
guidance="Inspect module for available public entry points and update adapter mapping.",
|
| 177 |
-
)
|
| 178 |
-
|
| 179 |
-
# ---------------------------------------------------------------------
|
| 180 |
-
# __main__ module wrappers (source.src.scanpy.__main__)
|
| 181 |
-
# ---------------------------------------------------------------------
|
| 182 |
-
def instance_main(self) -> Dict[str, Any]:
|
| 183 |
-
"""
|
| 184 |
-
Return the imported __main__ module instance.
|
| 185 |
-
|
| 186 |
-
Returns:
|
| 187 |
-
dict: Unified response containing __main__ module object.
|
| 188 |
-
"""
|
| 189 |
-
mod = self._get_module("main")
|
| 190 |
-
if mod is None:
|
| 191 |
-
return self._error(
|
| 192 |
-
"__main__ module is unavailable.",
|
| 193 |
-
guidance="Ensure source.src.scanpy.__main__ is importable.",
|
| 194 |
-
)
|
| 195 |
-
return self._ok({"module": mod}, "__main__ module loaded.")
|
| 196 |
-
|
| 197 |
-
def call_module_main(self, args: Optional[list] = None) -> Dict[str, Any]:
|
| 198 |
-
"""
|
| 199 |
-
Invoke python -m scanpy style entry point behavior.
|
| 200 |
-
|
| 201 |
-
Parameters:
|
| 202 |
-
args: Optional list of arguments for module main function.
|
| 203 |
-
|
| 204 |
-
Returns:
|
| 205 |
-
dict: Unified response with execution outcome.
|
| 206 |
-
"""
|
| 207 |
-
mod = self._get_module("main")
|
| 208 |
-
if mod is None:
|
| 209 |
-
return self._error(
|
| 210 |
-
"__main__ module is unavailable in current mode.",
|
| 211 |
-
guidance="Use environment with full dependencies or run CLI directly.",
|
| 212 |
-
)
|
| 213 |
-
for candidate in ("main",):
|
| 214 |
-
if hasattr(mod, candidate) and callable(getattr(mod, candidate)):
|
| 215 |
-
fn = getattr(mod, candidate)
|
| 216 |
-
if args is None:
|
| 217 |
-
return self._safe_call(fn)
|
| 218 |
-
return self._safe_call(fn, args)
|
| 219 |
-
return self._error(
|
| 220 |
-
"No callable main() found in source.src.scanpy.__main__.",
|
| 221 |
-
guidance="Verify the repository version and update adapter expectations.",
|
| 222 |
-
)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import importlib
|
| 4 |
+
import inspect
|
| 5 |
+
import pkgutil
|
| 6 |
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
|
| 10 |
+
SOURCE_DIR = Path(__file__).resolve().parents[2] / "source"
|
| 11 |
+
if SOURCE_DIR.exists():
|
| 12 |
+
source_path = str(SOURCE_DIR)
|
| 13 |
+
if source_path not in sys.path:
|
| 14 |
+
sys.path.insert(0, source_path)
|
| 15 |
|
| 16 |
|
| 17 |
class Adapter:
|
| 18 |
+
def __init__(self, package_name: str = "scanpy") -> None:
|
| 19 |
+
self.package_name = package_name
|
| 20 |
+
self.loaded_modules: dict[str, Any] = {}
|
| 21 |
+
self.failed_modules: dict[str, str] = {}
|
| 22 |
+
self.mode = "normal"
|
| 23 |
+
self._instances: dict[str, Any] = {}
|
| 24 |
+
self._instance_counter = 0
|
| 25 |
+
|
| 26 |
+
def _discover_submodules(self) -> list[str]:
|
| 27 |
+
discovered = [self.package_name]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
try:
|
| 29 |
+
root_module = importlib.import_module(self.package_name)
|
| 30 |
+
except Exception:
|
| 31 |
+
return discovered
|
| 32 |
+
|
| 33 |
+
if hasattr(root_module, "__path__"):
|
| 34 |
+
for module_info in pkgutil.walk_packages(
|
| 35 |
+
root_module.__path__, prefix=f"{self.package_name}."
|
| 36 |
+
):
|
| 37 |
+
discovered.append(module_info.name)
|
| 38 |
+
return discovered
|
| 39 |
+
|
| 40 |
+
def load_modules(self) -> dict[str, Any]:
|
| 41 |
+
self.loaded_modules.clear()
|
| 42 |
+
self.failed_modules.clear()
|
| 43 |
+
|
| 44 |
+
for module_name in self._discover_submodules():
|
| 45 |
+
try:
|
| 46 |
+
self.loaded_modules[module_name] = importlib.import_module(module_name)
|
| 47 |
+
except Exception as exc:
|
| 48 |
+
self.failed_modules[module_name] = str(exc)
|
| 49 |
+
|
| 50 |
+
if not self.loaded_modules:
|
| 51 |
+
self.mode = "blackbox"
|
| 52 |
+
return {
|
| 53 |
+
"status": "fallback",
|
| 54 |
+
"mode": self.mode,
|
| 55 |
+
"message": "No modules loaded; operating in blackbox mode",
|
| 56 |
+
"loaded_count": 0,
|
| 57 |
+
"failed_count": len(self.failed_modules),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
self.mode = "normal"
|
| 61 |
+
return {
|
| 62 |
+
"status": "ok",
|
| 63 |
+
"mode": self.mode,
|
| 64 |
+
"loaded_count": len(self.loaded_modules),
|
| 65 |
+
"failed_count": len(self.failed_modules),
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
def health(self) -> dict[str, Any]:
|
| 69 |
+
if not self.loaded_modules and not self.failed_modules:
|
| 70 |
+
return self.load_modules()
|
| 71 |
+
|
| 72 |
+
status = "ok" if self.loaded_modules else "fallback"
|
| 73 |
+
return {
|
| 74 |
+
"status": status,
|
| 75 |
+
"mode": self.mode,
|
| 76 |
+
"loaded_count": len(self.loaded_modules),
|
| 77 |
+
"failed_count": len(self.failed_modules),
|
| 78 |
+
"package": self.package_name,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
def list_modules(self) -> dict[str, Any]:
|
| 82 |
+
if not self.loaded_modules and not self.failed_modules:
|
| 83 |
+
self.load_modules()
|
| 84 |
+
|
| 85 |
+
status = "ok" if self.loaded_modules else "fallback"
|
| 86 |
+
return {
|
| 87 |
+
"status": status,
|
| 88 |
+
"mode": self.mode,
|
| 89 |
+
"loaded_modules": sorted(self.loaded_modules.keys()),
|
| 90 |
+
"failed_modules": self.failed_modules,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
def list_symbols(self, module_name: str, limit: int = 200) -> dict[str, Any]:
|
| 94 |
+
module = self.loaded_modules.get(module_name)
|
| 95 |
+
if module is None:
|
| 96 |
+
try:
|
| 97 |
+
module = importlib.import_module(module_name)
|
| 98 |
+
self.loaded_modules[module_name] = module
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
return {
|
| 101 |
+
"status": "error",
|
| 102 |
+
"module": module_name,
|
| 103 |
+
"error": str(exc),
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
symbols = sorted(name for name, _ in inspect.getmembers(module))
|
| 107 |
+
return {
|
| 108 |
+
"status": "ok",
|
| 109 |
+
"module": module_name,
|
| 110 |
+
"count": len(symbols),
|
| 111 |
+
"symbols": symbols[:limit],
|
| 112 |
+
"truncated": len(symbols) > limit,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
def call_function(
|
| 116 |
+
self,
|
| 117 |
+
module_name: str,
|
| 118 |
+
function_name: str,
|
| 119 |
+
args: list[Any] | None = None,
|
| 120 |
+
kwargs: dict[str, Any] | None = None,
|
| 121 |
+
) -> dict[str, Any]:
|
| 122 |
+
args = args or []
|
| 123 |
+
kwargs = kwargs or {}
|
| 124 |
|
| 125 |
+
try:
|
| 126 |
+
module = self.loaded_modules.get(module_name)
|
| 127 |
+
if module is None:
|
| 128 |
+
module = importlib.import_module(module_name)
|
| 129 |
+
self.loaded_modules[module_name] = module
|
| 130 |
+
|
| 131 |
+
func = getattr(module, function_name)
|
| 132 |
+
if not callable(func):
|
| 133 |
+
return {
|
| 134 |
+
"status": "error",
|
| 135 |
+
"module": module_name,
|
| 136 |
+
"function": function_name,
|
| 137 |
+
"error": f"{function_name} is not callable",
|
| 138 |
+
}
|
| 139 |
|
| 140 |
+
result = func(*args, **kwargs)
|
| 141 |
+
return {
|
| 142 |
+
"status": "ok",
|
| 143 |
+
"module": module_name,
|
| 144 |
+
"function": function_name,
|
| 145 |
+
"result": result,
|
| 146 |
+
}
|
| 147 |
+
except Exception as exc:
|
| 148 |
+
return {
|
| 149 |
+
"status": "error",
|
| 150 |
+
"module": module_name,
|
| 151 |
+
"function": function_name,
|
| 152 |
+
"error": str(exc),
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
def create_instance(
|
| 156 |
+
self,
|
| 157 |
+
module_name: str,
|
| 158 |
+
class_name: str,
|
| 159 |
+
init_args: list[Any] | None = None,
|
| 160 |
+
init_kwargs: dict[str, Any] | None = None,
|
| 161 |
+
) -> dict[str, Any]:
|
| 162 |
+
init_args = init_args or []
|
| 163 |
+
init_kwargs = init_kwargs or {}
|
| 164 |
|
|
|
|
| 165 |
try:
|
| 166 |
+
module = self.loaded_modules.get(module_name)
|
| 167 |
+
if module is None:
|
| 168 |
+
module = importlib.import_module(module_name)
|
| 169 |
+
self.loaded_modules[module_name] = module
|
| 170 |
+
|
| 171 |
+
klass = getattr(module, class_name)
|
| 172 |
+
instance = klass(*init_args, **init_kwargs)
|
| 173 |
+
self._instance_counter += 1
|
| 174 |
+
instance_id = f"instance_{self._instance_counter}"
|
| 175 |
+
self._instances[instance_id] = instance
|
| 176 |
+
|
| 177 |
+
return {
|
| 178 |
+
"status": "ok",
|
| 179 |
+
"module": module_name,
|
| 180 |
+
"class": class_name,
|
| 181 |
+
"instance_id": instance_id,
|
| 182 |
+
}
|
| 183 |
except Exception as exc:
|
| 184 |
+
return {
|
| 185 |
+
"status": "error",
|
| 186 |
+
"module": module_name,
|
| 187 |
+
"class": class_name,
|
| 188 |
+
"error": str(exc),
|
| 189 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scanpy/mcp_output/mcp_plugin/main.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
MCP Service Auto-Wrapper - Auto-generated
|
| 3 |
-
"""
|
| 4 |
-
from mcp_service import create_app
|
| 5 |
|
| 6 |
-
|
| 7 |
-
"""Main entry point"""
|
| 8 |
-
app = create_app()
|
| 9 |
-
return app
|
| 10 |
|
|
|
|
| 11 |
if __name__ == "__main__":
|
| 12 |
-
app =
|
| 13 |
-
app.run()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
from mcp_service import create_app
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
# For local stdio use only (Claude Desktop / CLI), not for web or Docker deployment.
|
| 6 |
if __name__ == "__main__":
|
| 7 |
+
app = create_app()
|
| 8 |
+
app.run(transport="stdio")
|
scanpy/mcp_output/mcp_plugin/mcp_service.py
CHANGED
|
@@ -1,273 +1,355 @@
|
|
| 1 |
-
import
|
| 2 |
-
import sys
|
| 3 |
-
from typing import Any, Dict, List, Optional
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
if source_path not in sys.path:
|
| 10 |
-
sys.path.insert(0, source_path)
|
| 11 |
|
|
|
|
|
|
|
| 12 |
from fastmcp import FastMCP
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
try:
|
| 15 |
-
|
| 16 |
-
except
|
| 17 |
-
from
|
| 18 |
|
| 19 |
-
mcp = FastMCP("
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
-
def _ok(result: Any) ->
|
| 23 |
return {"success": True, "result": result, "error": None}
|
| 24 |
|
| 25 |
|
| 26 |
-
def
|
| 27 |
-
return {"success": False, "result": None, "error":
|
| 28 |
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
try:
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
@mcp.tool(
|
| 46 |
-
name="
|
| 47 |
-
description="
|
| 48 |
)
|
| 49 |
-
def
|
| 50 |
-
""
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
"""
|
| 59 |
try:
|
| 60 |
-
if
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
return _ok(
|
| 65 |
{
|
| 66 |
-
"
|
| 67 |
"n_obs": int(adata.n_obs),
|
| 68 |
"n_vars": int(adata.n_vars),
|
| 69 |
-
"obs_columns": list(map(str, adata.obs.columns.tolist())),
|
| 70 |
-
"var_columns": list(map(str, adata.var.columns.tolist())),
|
| 71 |
}
|
| 72 |
)
|
| 73 |
-
except Exception as
|
| 74 |
-
return
|
| 75 |
|
| 76 |
|
| 77 |
-
@mcp.tool(
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
)
|
| 81 |
-
def read_h5ad_summary(path: str) -> Dict[str, Any]:
|
| 82 |
-
"""
|
| 83 |
-
Read AnnData from disk and return summary stats.
|
| 84 |
-
|
| 85 |
-
Parameters:
|
| 86 |
-
path: Path to .h5ad file.
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
"""
|
| 91 |
try:
|
| 92 |
-
adata = sc.read_h5ad(
|
|
|
|
| 93 |
return _ok(
|
| 94 |
{
|
| 95 |
-
"
|
| 96 |
"n_obs": int(adata.n_obs),
|
| 97 |
"n_vars": int(adata.n_vars),
|
| 98 |
-
"
|
| 99 |
-
"
|
| 100 |
-
"obsm_keys": list(map(str, adata.obsm_keys())),
|
| 101 |
-
"uns_keys": list(map(str, adata.uns_keys())),
|
| 102 |
}
|
| 103 |
)
|
| 104 |
-
except Exception as
|
| 105 |
-
return
|
| 106 |
|
| 107 |
|
| 108 |
@mcp.tool(
|
| 109 |
name="preprocess_basic",
|
| 110 |
-
description="Run
|
| 111 |
)
|
| 112 |
def preprocess_basic(
|
| 113 |
-
|
| 114 |
-
min_genes: int = 200,
|
| 115 |
-
min_cells: int = 3,
|
| 116 |
target_sum: float = 10000.0,
|
|
|
|
| 117 |
n_top_genes: int = 2000,
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
) ->
|
| 121 |
-
"""
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
| 135 |
"""
|
| 136 |
try:
|
| 137 |
-
|
| 138 |
-
raise ValueError(f"Unknown dataset: {dataset_name}")
|
| 139 |
-
adata = getattr(sc.datasets, dataset_name)()
|
| 140 |
-
|
| 141 |
-
sc.pp.filter_cells(adata, min_genes=min_genes)
|
| 142 |
-
sc.pp.filter_genes(adata, min_cells=min_cells)
|
| 143 |
-
|
| 144 |
-
adata.var["mt"] = adata.var_names.str.upper().str.startswith("MT-")
|
| 145 |
-
sc.pp.calculate_qc_metrics(adata, qc_vars=["mt"], inplace=True)
|
| 146 |
-
|
| 147 |
-
if max_n_genes_by_counts is not None:
|
| 148 |
-
adata = adata[adata.obs["n_genes_by_counts"] < max_n_genes_by_counts].copy()
|
| 149 |
-
if max_pct_counts_mt is not None and "pct_counts_mt" in adata.obs:
|
| 150 |
-
adata = adata[adata.obs["pct_counts_mt"] < max_pct_counts_mt].copy()
|
| 151 |
-
|
| 152 |
sc.pp.normalize_total(adata, target_sum=target_sum)
|
| 153 |
-
|
|
|
|
| 154 |
sc.pp.highly_variable_genes(adata, n_top_genes=n_top_genes, inplace=True)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
return _ok(
|
| 158 |
{
|
| 159 |
-
"
|
| 160 |
"n_obs": int(adata.n_obs),
|
| 161 |
"n_vars": int(adata.n_vars),
|
| 162 |
-
"
|
|
|
|
|
|
|
| 163 |
}
|
| 164 |
)
|
| 165 |
-
except Exception as
|
| 166 |
-
return
|
| 167 |
|
| 168 |
|
| 169 |
@mcp.tool(
|
| 170 |
-
name="
|
| 171 |
-
description="Compute PCA, neighborhood graph, and UMAP embedding.",
|
| 172 |
)
|
| 173 |
-
def
|
| 174 |
-
|
| 175 |
-
n_pcs: int =
|
| 176 |
-
n_neighbors: int =
|
|
|
|
| 177 |
random_state: int = 0,
|
| 178 |
-
) ->
|
| 179 |
-
"""
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
| 190 |
"""
|
| 191 |
try:
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
adata = getattr(sc.datasets, dataset_name)()
|
| 195 |
-
|
| 196 |
-
sc.pp.normalize_total(adata, target_sum=1e4)
|
| 197 |
-
sc.pp.log1p(adata)
|
| 198 |
-
sc.pp.highly_variable_genes(adata, n_top_genes=2000, inplace=True)
|
| 199 |
-
if "highly_variable" in adata.var:
|
| 200 |
-
adata = adata[:, adata.var["highly_variable"]].copy()
|
| 201 |
-
|
| 202 |
-
sc.pp.scale(adata, max_value=10)
|
| 203 |
-
sc.tl.pca(adata, svd_solver="arpack", random_state=random_state)
|
| 204 |
sc.pp.neighbors(adata, n_neighbors=n_neighbors, n_pcs=n_pcs)
|
| 205 |
-
sc.tl.umap(adata, random_state=random_state)
|
| 206 |
-
|
| 207 |
-
umap = adata.obsm.get("X_umap")
|
| 208 |
-
umap_shape: List[int] = list(umap.shape) if umap is not None else [0, 0]
|
| 209 |
-
|
| 210 |
return _ok(
|
| 211 |
{
|
| 212 |
-
"
|
| 213 |
-
"
|
| 214 |
-
"
|
| 215 |
-
"umap_shape": umap_shape,
|
| 216 |
-
"obsm_keys": list(map(str, adata.obsm_keys())),
|
| 217 |
}
|
| 218 |
)
|
| 219 |
-
except Exception as
|
| 220 |
-
return
|
| 221 |
|
| 222 |
|
| 223 |
@mcp.tool(
|
| 224 |
-
name="
|
| 225 |
-
description="Run Leiden clustering
|
| 226 |
)
|
| 227 |
-
def
|
| 228 |
-
|
| 229 |
resolution: float = 1.0,
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
) ->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
"""
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
Parameters:
|
| 237 |
-
dataset_name: Built-in Scanpy dataset loader name.
|
| 238 |
-
resolution: Leiden resolution parameter.
|
| 239 |
-
n_neighbors: Graph neighbor count.
|
| 240 |
-
n_pcs: Number of PCs for graph construction.
|
| 241 |
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
"""
|
| 245 |
try:
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
adata = adata[:, adata.var["highly_variable"]].copy()
|
| 255 |
-
|
| 256 |
-
sc.pp.pca(adata)
|
| 257 |
-
sc.pp.neighbors(adata, n_neighbors=n_neighbors, n_pcs=n_pcs)
|
| 258 |
-
sc.tl.leiden(adata, resolution=resolution)
|
| 259 |
-
|
| 260 |
-
counts = adata.obs["leiden"].value_counts().to_dict()
|
| 261 |
return _ok(
|
| 262 |
{
|
| 263 |
-
"
|
| 264 |
-
"
|
| 265 |
-
"
|
| 266 |
-
"
|
| 267 |
}
|
| 268 |
)
|
| 269 |
-
except Exception as
|
| 270 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
|
| 273 |
def create_app() -> FastMCP:
|
|
@@ -275,4 +357,4 @@ def create_app() -> FastMCP:
|
|
| 275 |
|
| 276 |
|
| 277 |
if __name__ == "__main__":
|
| 278 |
-
mcp.run()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
import numpy as np
|
| 9 |
+
from anndata import AnnData
|
| 10 |
from fastmcp import FastMCP
|
| 11 |
|
| 12 |
+
SOURCE_DIR = Path(__file__).resolve().parents[2] / "source"
|
| 13 |
+
if SOURCE_DIR.exists():
|
| 14 |
+
source_path = str(SOURCE_DIR)
|
| 15 |
+
if source_path not in sys.path:
|
| 16 |
+
sys.path.insert(0, source_path)
|
| 17 |
+
|
| 18 |
+
import scanpy as sc
|
| 19 |
+
|
| 20 |
try:
|
| 21 |
+
from .adapter import Adapter
|
| 22 |
+
except ImportError:
|
| 23 |
+
from adapter import Adapter
|
| 24 |
|
| 25 |
+
mcp = FastMCP("scanpy-mcp-service")
|
| 26 |
+
adapter = Adapter("scanpy")
|
| 27 |
+
DATASETS: dict[str, AnnData] = {}
|
| 28 |
|
| 29 |
|
| 30 |
+
def _ok(result: Any) -> dict[str, Any]:
|
| 31 |
return {"success": True, "result": result, "error": None}
|
| 32 |
|
| 33 |
|
| 34 |
+
def _error(message: str) -> dict[str, Any]:
|
| 35 |
+
return {"success": False, "result": None, "error": message}
|
| 36 |
|
| 37 |
|
| 38 |
+
def _get_dataset(dataset_key: str) -> AnnData:
|
| 39 |
+
if dataset_key not in DATASETS:
|
| 40 |
+
raise KeyError(f"Dataset '{dataset_key}' not found. Create or load it first.")
|
| 41 |
+
return DATASETS[dataset_key]
|
| 42 |
|
| 43 |
+
|
| 44 |
+
@mcp.tool(name="scanpy_health", description="Return Scanpy MCP service and adapter health.")
|
| 45 |
+
def scanpy_health() -> dict[str, Any]:
|
| 46 |
+
"""Get current service health and module loading status."""
|
| 47 |
try:
|
| 48 |
+
health_info = adapter.health()
|
| 49 |
+
result = {
|
| 50 |
+
"service": "scanpy-mcp-service",
|
| 51 |
+
"scanpy_version": getattr(sc, "__version__", "unknown"),
|
| 52 |
+
"adapter": health_info,
|
| 53 |
+
"datasets_loaded": sorted(DATASETS.keys()),
|
| 54 |
+
}
|
| 55 |
+
return _ok(result)
|
| 56 |
+
except Exception as exc:
|
| 57 |
+
return _error(str(exc))
|
| 58 |
|
| 59 |
|
| 60 |
@mcp.tool(
|
| 61 |
+
name="create_synthetic_adata",
|
| 62 |
+
description="Create an in-memory synthetic AnnData object for analysis workflows.",
|
| 63 |
)
|
| 64 |
+
def create_synthetic_adata(
|
| 65 |
+
dataset_key: str = "demo",
|
| 66 |
+
n_obs: int = 200,
|
| 67 |
+
n_vars: int = 50,
|
| 68 |
+
seed: int = 0,
|
| 69 |
+
) -> dict[str, Any]:
|
| 70 |
+
"""Create synthetic dataset.
|
| 71 |
+
|
| 72 |
+
Parameters
|
| 73 |
+
----------
|
| 74 |
+
dataset_key
|
| 75 |
+
Key used to store the dataset in memory.
|
| 76 |
+
n_obs
|
| 77 |
+
Number of cells (observations).
|
| 78 |
+
n_vars
|
| 79 |
+
Number of genes (variables).
|
| 80 |
+
seed
|
| 81 |
+
Random seed for reproducibility.
|
| 82 |
"""
|
| 83 |
try:
|
| 84 |
+
if n_obs <= 0 or n_vars <= 0:
|
| 85 |
+
return _error("n_obs and n_vars must be positive integers")
|
| 86 |
+
|
| 87 |
+
rng = np.random.default_rng(seed)
|
| 88 |
+
matrix = rng.poisson(lam=1.2, size=(n_obs, n_vars)).astype(np.float32)
|
| 89 |
+
adata = AnnData(X=matrix)
|
| 90 |
+
adata.obs_names = [f"cell_{i}" for i in range(n_obs)]
|
| 91 |
+
adata.var_names = [f"gene_{i}" for i in range(n_vars)]
|
| 92 |
+
DATASETS[dataset_key] = adata
|
| 93 |
return _ok(
|
| 94 |
{
|
| 95 |
+
"dataset_key": dataset_key,
|
| 96 |
"n_obs": int(adata.n_obs),
|
| 97 |
"n_vars": int(adata.n_vars),
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
)
|
| 100 |
+
except Exception as exc:
|
| 101 |
+
return _error(str(exc))
|
| 102 |
|
| 103 |
|
| 104 |
+
@mcp.tool(name="load_h5ad", description="Load a local .h5ad file into in-memory dataset registry.")
|
| 105 |
+
def load_h5ad(file_path: str, dataset_key: str = "adata") -> dict[str, Any]:
|
| 106 |
+
"""Load an h5ad dataset from local filesystem.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
Parameters
|
| 109 |
+
----------
|
| 110 |
+
file_path
|
| 111 |
+
Path to a local .h5ad file.
|
| 112 |
+
dataset_key
|
| 113 |
+
Key used for storing the dataset in memory.
|
| 114 |
"""
|
| 115 |
try:
|
| 116 |
+
adata = sc.read_h5ad(file_path)
|
| 117 |
+
DATASETS[dataset_key] = adata
|
| 118 |
return _ok(
|
| 119 |
{
|
| 120 |
+
"dataset_key": dataset_key,
|
| 121 |
"n_obs": int(adata.n_obs),
|
| 122 |
"n_vars": int(adata.n_vars),
|
| 123 |
+
"obs_columns": list(adata.obs.columns),
|
| 124 |
+
"var_columns": list(adata.var.columns),
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
)
|
| 127 |
+
except Exception as exc:
|
| 128 |
+
return _error(str(exc))
|
| 129 |
|
| 130 |
|
| 131 |
@mcp.tool(
|
| 132 |
name="preprocess_basic",
|
| 133 |
+
description="Run basic preprocessing: normalize_total, optional log1p, HVG selection, optional scale.",
|
| 134 |
)
|
| 135 |
def preprocess_basic(
|
| 136 |
+
dataset_key: str,
|
|
|
|
|
|
|
| 137 |
target_sum: float = 10000.0,
|
| 138 |
+
apply_log1p: bool = True,
|
| 139 |
n_top_genes: int = 2000,
|
| 140 |
+
scale_data: bool = False,
|
| 141 |
+
max_value: float = 10.0,
|
| 142 |
+
) -> dict[str, Any]:
|
| 143 |
+
"""Preprocess a dataset in place.
|
| 144 |
+
|
| 145 |
+
Parameters
|
| 146 |
+
----------
|
| 147 |
+
dataset_key
|
| 148 |
+
Dataset key in memory registry.
|
| 149 |
+
target_sum
|
| 150 |
+
Target total counts after normalization.
|
| 151 |
+
apply_log1p
|
| 152 |
+
Whether to apply log1p transform.
|
| 153 |
+
n_top_genes
|
| 154 |
+
Number of highly variable genes to keep.
|
| 155 |
+
scale_data
|
| 156 |
+
Whether to z-score scale genes.
|
| 157 |
+
max_value
|
| 158 |
+
Clipping value for scaling.
|
| 159 |
"""
|
| 160 |
try:
|
| 161 |
+
adata = _get_dataset(dataset_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
sc.pp.normalize_total(adata, target_sum=target_sum)
|
| 163 |
+
if apply_log1p:
|
| 164 |
+
sc.pp.log1p(adata)
|
| 165 |
sc.pp.highly_variable_genes(adata, n_top_genes=n_top_genes, inplace=True)
|
| 166 |
+
if scale_data:
|
| 167 |
+
sc.pp.scale(adata, max_value=max_value)
|
| 168 |
return _ok(
|
| 169 |
{
|
| 170 |
+
"dataset_key": dataset_key,
|
| 171 |
"n_obs": int(adata.n_obs),
|
| 172 |
"n_vars": int(adata.n_vars),
|
| 173 |
+
"hvg_selected": int(adata.var.get("highly_variable", []).sum())
|
| 174 |
+
if "highly_variable" in adata.var
|
| 175 |
+
else 0,
|
| 176 |
}
|
| 177 |
)
|
| 178 |
+
except Exception as exc:
|
| 179 |
+
return _error(str(exc))
|
| 180 |
|
| 181 |
|
| 182 |
@mcp.tool(
|
| 183 |
+
name="run_pca_neighbors_umap",
|
| 184 |
+
description="Compute PCA, neighborhood graph, and UMAP embedding for a dataset.",
|
| 185 |
)
|
| 186 |
+
def run_pca_neighbors_umap(
|
| 187 |
+
dataset_key: str,
|
| 188 |
+
n_pcs: int = 50,
|
| 189 |
+
n_neighbors: int = 15,
|
| 190 |
+
min_dist: float = 0.5,
|
| 191 |
random_state: int = 0,
|
| 192 |
+
) -> dict[str, Any]:
|
| 193 |
+
"""Run core embedding workflow.
|
| 194 |
+
|
| 195 |
+
Parameters
|
| 196 |
+
----------
|
| 197 |
+
dataset_key
|
| 198 |
+
Dataset key in memory registry.
|
| 199 |
+
n_pcs
|
| 200 |
+
Number of principal components.
|
| 201 |
+
n_neighbors
|
| 202 |
+
Number of neighbors for graph construction.
|
| 203 |
+
min_dist
|
| 204 |
+
UMAP min_dist parameter.
|
| 205 |
+
random_state
|
| 206 |
+
Random state for reproducibility.
|
| 207 |
"""
|
| 208 |
try:
|
| 209 |
+
adata = _get_dataset(dataset_key)
|
| 210 |
+
sc.tl.pca(adata, n_comps=n_pcs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
sc.pp.neighbors(adata, n_neighbors=n_neighbors, n_pcs=n_pcs)
|
| 212 |
+
sc.tl.umap(adata, min_dist=min_dist, random_state=random_state)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
return _ok(
|
| 214 |
{
|
| 215 |
+
"dataset_key": dataset_key,
|
| 216 |
+
"obsm_keys": list(adata.obsm.keys()),
|
| 217 |
+
"uns_keys": list(adata.uns.keys()),
|
|
|
|
|
|
|
| 218 |
}
|
| 219 |
)
|
| 220 |
+
except Exception as exc:
|
| 221 |
+
return _error(str(exc))
|
| 222 |
|
| 223 |
|
| 224 |
@mcp.tool(
|
| 225 |
+
name="run_leiden",
|
| 226 |
+
description="Run Leiden clustering and write labels to adata.obs.",
|
| 227 |
)
|
| 228 |
+
def run_leiden(
|
| 229 |
+
dataset_key: str,
|
| 230 |
resolution: float = 1.0,
|
| 231 |
+
key_added: str = "leiden",
|
| 232 |
+
random_state: int = 0,
|
| 233 |
+
) -> dict[str, Any]:
|
| 234 |
+
"""Run Leiden clustering.
|
| 235 |
+
|
| 236 |
+
Parameters
|
| 237 |
+
----------
|
| 238 |
+
dataset_key
|
| 239 |
+
Dataset key in memory registry.
|
| 240 |
+
resolution
|
| 241 |
+
Clustering granularity.
|
| 242 |
+
key_added
|
| 243 |
+
Column name in adata.obs storing cluster labels.
|
| 244 |
+
random_state
|
| 245 |
+
Random state.
|
| 246 |
"""
|
| 247 |
+
try:
|
| 248 |
+
adata = _get_dataset(dataset_key)
|
| 249 |
+
sc.tl.leiden(
|
| 250 |
+
adata,
|
| 251 |
+
resolution=resolution,
|
| 252 |
+
key_added=key_added,
|
| 253 |
+
random_state=random_state,
|
| 254 |
+
)
|
| 255 |
+
counts = adata.obs[key_added].value_counts().to_dict()
|
| 256 |
+
return _ok(
|
| 257 |
+
{
|
| 258 |
+
"dataset_key": dataset_key,
|
| 259 |
+
"key_added": key_added,
|
| 260 |
+
"cluster_counts": {str(k): int(v) for k, v in counts.items()},
|
| 261 |
+
}
|
| 262 |
+
)
|
| 263 |
+
except Exception as exc:
|
| 264 |
+
return _error(str(exc))
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
+
@mcp.tool(
|
| 268 |
+
name="rank_marker_genes",
|
| 269 |
+
description="Rank marker genes by group using tl.rank_genes_groups.",
|
| 270 |
+
)
|
| 271 |
+
def rank_marker_genes(
|
| 272 |
+
dataset_key: str,
|
| 273 |
+
groupby: str = "leiden",
|
| 274 |
+
method: str = "wilcoxon",
|
| 275 |
+
n_genes: int = 10,
|
| 276 |
+
) -> dict[str, Any]:
|
| 277 |
+
"""Rank marker genes.
|
| 278 |
+
|
| 279 |
+
Parameters
|
| 280 |
+
----------
|
| 281 |
+
dataset_key
|
| 282 |
+
Dataset key in memory registry.
|
| 283 |
+
groupby
|
| 284 |
+
adata.obs column used for group comparison.
|
| 285 |
+
method
|
| 286 |
+
Statistical method for ranking.
|
| 287 |
+
n_genes
|
| 288 |
+
Number of genes per group to return.
|
| 289 |
"""
|
| 290 |
try:
|
| 291 |
+
adata = _get_dataset(dataset_key)
|
| 292 |
+
sc.tl.rank_genes_groups(adata, groupby=groupby, method=method, n_genes=n_genes)
|
| 293 |
+
ranked = adata.uns.get("rank_genes_groups", {})
|
| 294 |
+
names = ranked.get("names")
|
| 295 |
+
output: dict[str, list[str]] = {}
|
| 296 |
+
if names is not None and hasattr(names, "dtype") and names.dtype.names:
|
| 297 |
+
for group_name in names.dtype.names:
|
| 298 |
+
output[group_name] = [str(x) for x in names[group_name][:n_genes]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
return _ok(
|
| 300 |
{
|
| 301 |
+
"dataset_key": dataset_key,
|
| 302 |
+
"groupby": groupby,
|
| 303 |
+
"method": method,
|
| 304 |
+
"top_genes": output,
|
| 305 |
}
|
| 306 |
)
|
| 307 |
+
except Exception as exc:
|
| 308 |
+
return _error(str(exc))
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@mcp.tool(
|
| 312 |
+
name="adapter_call_function",
|
| 313 |
+
description="Call any importable function via adapter using JSON-encoded args/kwargs.",
|
| 314 |
+
)
|
| 315 |
+
def adapter_call_function(
|
| 316 |
+
module_name: str,
|
| 317 |
+
function_name: str,
|
| 318 |
+
args_json: str = "[]",
|
| 319 |
+
kwargs_json: str = "{}",
|
| 320 |
+
) -> dict[str, Any]:
|
| 321 |
+
"""Call arbitrary function through adapter.
|
| 322 |
+
|
| 323 |
+
Parameters
|
| 324 |
+
----------
|
| 325 |
+
module_name
|
| 326 |
+
Python module path.
|
| 327 |
+
function_name
|
| 328 |
+
Callable name inside module.
|
| 329 |
+
args_json
|
| 330 |
+
JSON array string for positional arguments.
|
| 331 |
+
kwargs_json
|
| 332 |
+
JSON object string for keyword arguments.
|
| 333 |
+
"""
|
| 334 |
+
try:
|
| 335 |
+
args = json.loads(args_json)
|
| 336 |
+
kwargs = json.loads(kwargs_json)
|
| 337 |
+
if not isinstance(args, list):
|
| 338 |
+
return _error("args_json must decode to a JSON list")
|
| 339 |
+
if not isinstance(kwargs, dict):
|
| 340 |
+
return _error("kwargs_json must decode to a JSON object")
|
| 341 |
+
|
| 342 |
+
result = adapter.call_function(
|
| 343 |
+
module_name=module_name,
|
| 344 |
+
function_name=function_name,
|
| 345 |
+
args=args,
|
| 346 |
+
kwargs=kwargs,
|
| 347 |
+
)
|
| 348 |
+
if result.get("status") == "error":
|
| 349 |
+
return _error(result.get("error", "adapter call failed"))
|
| 350 |
+
return _ok(result)
|
| 351 |
+
except Exception as exc:
|
| 352 |
+
return _error(str(exc))
|
| 353 |
|
| 354 |
|
| 355 |
def create_app() -> FastMCP:
|
|
|
|
| 357 |
|
| 358 |
|
| 359 |
if __name__ == "__main__":
|
| 360 |
+
mcp.run(transport="stdio")
|
scanpy/mcp_output/requirements.txt
CHANGED
|
@@ -1,25 +1,6 @@
|
|
| 1 |
fastmcp
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
h5py>=3.11
|
| 8 |
-
joblib
|
| 9 |
-
matplotlib>=3.9
|
| 10 |
-
natsort
|
| 11 |
-
networkx>=2.8.8
|
| 12 |
-
numba>=0.60
|
| 13 |
-
numpy>=2
|
| 14 |
-
packaging>=25
|
| 15 |
-
pandas>=2.2.2
|
| 16 |
-
patsy
|
| 17 |
-
pynndescent>=0.5.13
|
| 18 |
-
scikit-learn>=1.4.2
|
| 19 |
-
scipy>=1.13
|
| 20 |
-
seaborn>=0.13.2
|
| 21 |
-
session-info2
|
| 22 |
-
statsmodels>=0.14.5
|
| 23 |
-
tqdm
|
| 24 |
-
typing-extensions; python_version<'3.13'
|
| 25 |
-
umap-learn>=0.5.7
|
|
|
|
| 1 |
fastmcp
|
| 2 |
+
scanpy
|
| 3 |
+
anndata
|
| 4 |
+
numpy
|
| 5 |
+
pandas
|
| 6 |
+
scipy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scanpy/mcp_output/start_mcp.py
CHANGED
|
@@ -1,30 +1,38 @@
|
|
|
|
|
| 1 |
|
| 2 |
-
"""
|
| 3 |
-
MCP Service Startup Entry
|
| 4 |
-
"""
|
| 5 |
-
import sys
|
| 6 |
import os
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
if
|
| 11 |
-
sys.path.insert(0,
|
| 12 |
|
| 13 |
from mcp_service import create_app
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
app = create_app()
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
if transport == "http":
|
| 24 |
app.run(transport="http", host="0.0.0.0", port=port)
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
if __name__ == "__main__":
|
| 30 |
main()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import os
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
|
| 7 |
+
PLUGIN_DIR = Path(__file__).resolve().parent / "mcp_plugin"
|
| 8 |
+
plugin_path = str(PLUGIN_DIR)
|
| 9 |
+
if plugin_path not in sys.path:
|
| 10 |
+
sys.path.insert(0, plugin_path)
|
| 11 |
|
| 12 |
from mcp_service import create_app
|
| 13 |
|
| 14 |
+
|
| 15 |
+
def main() -> None:
|
| 16 |
+
transport = os.getenv("MCP_TRANSPORT", "stdio").strip().lower()
|
| 17 |
+
port_value = os.getenv("MCP_PORT", "8000").strip()
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
port = int(port_value)
|
| 21 |
+
except ValueError:
|
| 22 |
+
raise ValueError(f"Invalid MCP_PORT: {port_value}")
|
| 23 |
+
|
| 24 |
app = create_app()
|
| 25 |
+
|
| 26 |
+
if transport == "stdio":
|
| 27 |
+
app.run(transport="stdio")
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
if transport == "http":
|
| 31 |
app.run(transport="http", host="0.0.0.0", port=port)
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
raise ValueError(f"Unsupported MCP_TRANSPORT: {transport}")
|
| 35 |
+
|
| 36 |
|
| 37 |
if __name__ == "__main__":
|
| 38 |
main()
|