ghh1125 commited on
Commit
1a3c24f
·
verified ·
1 Parent(s): 7e19870

Upload 14 files

Browse files
Dockerfile CHANGED
@@ -1,17 +1,22 @@
1
- FROM python:3.10
2
 
3
- RUN useradd -m -u 1000 user && python -m pip install --upgrade pip
4
- USER user
5
- ENV PATH="/home/user/.local/bin:$PATH"
 
6
 
7
  WORKDIR /app
8
 
9
- COPY --chown=user ./requirements.txt requirements.txt
10
- RUN pip install --no-cache-dir --upgrade -r requirements.txt
11
 
12
- COPY --chown=user . /app
13
- ENV MCP_TRANSPORT=http
14
- ENV MCP_PORT=7860
 
 
 
 
 
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: Scanpy
3
- emoji: 👁
4
  colorFrom: blue
5
- colorTo: yellow
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 fastapi import FastAPI
 
2
  import os
3
  import sys
 
 
 
 
4
 
5
- mcp_plugin_path = os.path.join(os.path.dirname(__file__), "scanpy", "mcp_output", "mcp_plugin")
6
- sys.path.insert(0, mcp_plugin_path)
 
 
 
 
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
- "service": "Scanpy MCP Service",
18
- "version": "1.0.0",
19
- "status": "running",
20
- "transport": os.environ.get("MCP_TRANSPORT", "http")
21
  }
22
 
 
23
  @app.get("/health")
24
- def health_check():
25
- return {"status": "healthy", "service": "scanpy MCP"}
 
26
 
27
  @app.get("/tools")
28
- def list_tools():
29
- try:
30
- from mcp_service import create_app
31
- mcp_app = create_app()
32
- tools = []
33
- for tool_name, tool_func in mcp_app.tools.items():
34
- tools.append({
35
- "name": tool_name,
36
- "description": tool_func.__doc__ or "No description available"
37
- })
38
- return {"tools": tools}
39
- except Exception as e:
40
- return {"error": f"Failed to load tools: {str(e)}"}
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[standard]
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
- $entryName = if ($env:MCP_ENTRY_NAME) { $env:MCP_ENTRY_NAME } else { "scanpy" }
4
- $entryUrl = if ($env:MCP_ENTRY_URL) { $env:MCP_ENTRY_URL } else { "http://localhost:7918/mcp" }
5
- $imageName = if ($env:MCP_IMAGE_NAME) { $env:MCP_IMAGE_NAME } else { "scanpy-mcp" }
6
- $mcpDir = Join-Path $env:USERPROFILE ".cursor"
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 7918:7860 $imageName
 
 
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
- cd "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
4
- mcp_entry_name="${MCP_ENTRY_NAME:-scanpy}"
5
- mcp_entry_url="${MCP_ENTRY_URL:-http://localhost:7918/mcp}"
6
- mcp_dir="${HOME}/.cursor"
7
- mcp_path="${mcp_dir}/mcp.json"
8
- mkdir -p "${mcp_dir}"
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 (Model Context Protocol) Service README
2
 
3
- ## 1) Project Introduction
4
 
5
- This service exposes core **Scanpy** single-cell analysis capabilities through an MCP (Model Context Protocol) interface.
6
- It is designed for developer workflows that need programmatic access to:
7
 
8
- - Data I/O for AnnData and 10x formats
9
- - Preprocessing (QC, normalization, HVG selection, PCA, neighbor graph)
10
- - Analysis tools (Leiden/Louvain clustering, UMAP/t-SNE, PAGA, marker ranking)
11
- - Plotting helpers (UMAP/scatter, dotplot, matrixplot, violin, etc.)
12
- - Dataset loading and tabular extraction utilities
13
 
14
- Repository: https://github.com/scverse/scanpy
 
 
15
 
16
- ---
 
 
17
 
18
- ## 2) Installation Method
 
 
19
 
20
- ### Requirements
21
- Core runtime dependencies typically include:
 
22
 
23
- - anndata, numpy, scipy, pandas
24
- - matplotlib, scikit-learn, h5py
25
- - numba, networkx, packaging
26
 
27
- Common optional dependencies (feature-dependent):
 
 
28
 
29
- - umap-learn
30
- - igraph / python-igraph, leidenalg, louvain
31
- - dask, zarr
32
- - statsmodels, seaborn
33
- - harmonypy, bbknn, scanorama, magic-impute, palantir, phate, phenograph
34
 
35
- ### Install
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
- ## 3) Quick Start
 
 
 
 
 
 
44
 
45
- Typical service flow:
46
 
47
- 1. Load data (`read_h5ad`, `read_10x_h5`, `read_10x_mtx`, or dataset loaders such as `pbmc3k`)
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
- Example pipeline sequence:
53
- - read → qc/filter → normalize/log1p → hvg → pca → neighbors → leiden → umap → marker ranking
 
 
54
 
55
- ---
56
 
57
- ## 4) Available Tools and Endpoints List
 
 
58
 
59
- Recommended MCP (Model Context Protocol) service endpoints (grouped by module):
60
 
61
- ### I/O
62
- - `read`, `read_h5ad`, `read_csv`, `read_loom` — Load AnnData-compatible files
63
- - `read_10x_h5`, `read_10x_mtx` — Read 10x Genomics data
64
- - `write` — Persist processed AnnData objects
65
 
66
- ### Datasets
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 os
 
 
 
 
2
  import sys
3
- from typing import Any, Callable, Dict, Optional
 
4
 
5
- source_path = os.path.join(
6
- os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
7
- "source",
8
- )
9
- sys.path.insert(0, source_path)
10
 
11
 
12
  class Adapter:
13
- """
14
- MCP import-mode adapter for the Scanpy repository.
15
-
16
- This adapter prioritizes direct imports from repository source code and falls back
17
- to a lightweight CLI-compatible mode when imports are unavailable.
18
- """
19
-
20
- def __init__(self) -> None:
21
- self.mode = "import"
22
- self._modules: Dict[str, Any] = {}
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
- import source.src.scanpy as scanpy_pkg
46
- import source.src.scanpy.cli as scanpy_cli
47
- import source.src.scanpy.__main__ as scanpy_main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
- self._modules["scanpy"] = scanpy_pkg
50
- self._modules["cli"] = scanpy_cli
51
- self._modules["main"] = scanpy_main
52
- except Exception as exc:
53
- self.mode = "cli"
54
- self._import_error = str(exc)
 
 
 
 
 
 
 
 
55
 
56
- def _get_module(self, key: str) -> Optional[Any]:
57
- return self._modules.get(key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
- def _safe_call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Dict[str, Any]:
60
  try:
61
- result = func(*args, **kwargs)
62
- return self._ok({"result": result})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  except Exception as exc:
64
- return self._error(
65
- "Function execution failed.",
66
- guidance="Check arguments and data schema, then retry.",
67
- exc=exc,
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
- def main():
7
- """Main entry point"""
8
- app = create_app()
9
- return app
10
 
 
11
  if __name__ == "__main__":
12
- app = main()
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 os
2
- import sys
3
- from typing import Any, Dict, List, Optional
4
 
5
- source_path = os.path.join(
6
- os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
7
- "source",
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
- import scanpy as sc
16
- except Exception:
17
- from src.scanpy import __init__ as sc # type: ignore
18
 
19
- mcp = FastMCP("scanpy_mcp_service")
 
 
20
 
21
 
22
- def _ok(result: Any) -> Dict[str, Any]:
23
  return {"success": True, "result": result, "error": None}
24
 
25
 
26
- def _err(exc: Exception) -> Dict[str, Any]:
27
- return {"success": False, "result": None, "error": str(exc)}
28
 
29
 
30
- @mcp.tool(name="scanpy_version", description="Get installed Scanpy version information.")
31
- def scanpy_version() -> Dict[str, Any]:
32
- """
33
- Return Scanpy version.
34
 
35
- Returns:
36
- Dict with success/result/error. result is the version string.
37
- """
 
38
  try:
39
- version = getattr(sc, "__version__", "unknown")
40
- return _ok(version)
41
- except Exception as e:
42
- return _err(e)
 
 
 
 
 
 
43
 
44
 
45
  @mcp.tool(
46
- name="load_builtin_dataset",
47
- description="Load a built-in Scanpy dataset by name.",
48
  )
49
- def load_builtin_dataset(dataset_name: str) -> Dict[str, Any]:
50
- """
51
- Load a Scanpy built-in dataset.
52
-
53
- Parameters:
54
- dataset_name: Name of dataset function under sc.datasets (e.g., 'pbmc3k').
55
-
56
- Returns:
57
- Dict with success/result/error. result includes shape and metadata keys.
 
 
 
 
 
 
 
 
 
58
  """
59
  try:
60
- if not hasattr(sc.datasets, dataset_name):
61
- raise ValueError(f"Unknown dataset: {dataset_name}")
62
- ds_fn = getattr(sc.datasets, dataset_name)
63
- adata = ds_fn()
 
 
 
 
 
64
  return _ok(
65
  {
66
- "dataset": dataset_name,
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 e:
74
- return _err(e)
75
 
76
 
77
- @mcp.tool(
78
- name="read_h5ad_summary",
79
- description="Read an .h5ad file and return high-level summary.",
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
- Returns:
89
- Dict with success/result/error. result includes dimensions and annotation keys.
 
 
 
 
90
  """
91
  try:
92
- adata = sc.read_h5ad(path)
 
93
  return _ok(
94
  {
95
- "path": path,
96
  "n_obs": int(adata.n_obs),
97
  "n_vars": int(adata.n_vars),
98
- "obs_keys": list(map(str, adata.obs_keys())),
99
- "var_keys": list(map(str, adata.var_keys())),
100
- "obsm_keys": list(map(str, adata.obsm_keys())),
101
- "uns_keys": list(map(str, adata.uns_keys())),
102
  }
103
  )
104
- except Exception as e:
105
- return _err(e)
106
 
107
 
108
  @mcp.tool(
109
  name="preprocess_basic",
110
- description="Run standard Scanpy preprocessing pipeline on a dataset.",
111
  )
112
  def preprocess_basic(
113
- dataset_name: str = "pbmc3k",
114
- min_genes: int = 200,
115
- min_cells: int = 3,
116
  target_sum: float = 10000.0,
 
117
  n_top_genes: int = 2000,
118
- max_n_genes_by_counts: Optional[int] = None,
119
- max_pct_counts_mt: Optional[float] = None,
120
- ) -> Dict[str, Any]:
121
- """
122
- Execute a lightweight preprocessing workflow.
123
-
124
- Parameters:
125
- dataset_name: Built-in Scanpy dataset loader name.
126
- min_genes: Minimum genes per cell filter.
127
- min_cells: Minimum cells per gene filter.
128
- target_sum: Total-count normalization target.
129
- n_top_genes: Number of HVGs to keep.
130
- max_n_genes_by_counts: Optional cell QC upper bound.
131
- max_pct_counts_mt: Optional mitochondrial percentage upper bound.
132
-
133
- Returns:
134
- Dict with success/result/error. result includes filtered dimensions and HVG count.
 
 
135
  """
136
  try:
137
- if not hasattr(sc.datasets, dataset_name):
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
- sc.pp.log1p(adata)
 
154
  sc.pp.highly_variable_genes(adata, n_top_genes=n_top_genes, inplace=True)
155
-
156
- hvg_count = int(adata.var["highly_variable"].sum()) if "highly_variable" in adata.var else 0
157
  return _ok(
158
  {
159
- "dataset": dataset_name,
160
  "n_obs": int(adata.n_obs),
161
  "n_vars": int(adata.n_vars),
162
- "highly_variable_genes": hvg_count,
 
 
163
  }
164
  )
165
- except Exception as e:
166
- return _err(e)
167
 
168
 
169
  @mcp.tool(
170
- name="compute_neighbors_and_umap",
171
- description="Compute PCA, neighborhood graph, and UMAP embedding.",
172
  )
173
- def compute_neighbors_and_umap(
174
- dataset_name: str = "pbmc3k",
175
- n_pcs: int = 30,
176
- n_neighbors: int = 10,
 
177
  random_state: int = 0,
178
- ) -> Dict[str, Any]:
179
- """
180
- Run dimensionality reduction and graph embedding workflow.
181
-
182
- Parameters:
183
- dataset_name: Built-in Scanpy dataset loader name.
184
- n_pcs: Number of principal components for neighbors graph.
185
- n_neighbors: Number of neighbors in graph construction.
186
- random_state: Random seed for PCA/UMAP reproducibility.
187
-
188
- Returns:
189
- Dict with success/result/error. result includes embedding presence and shape.
 
 
 
190
  """
191
  try:
192
- if not hasattr(sc.datasets, dataset_name):
193
- raise ValueError(f"Unknown dataset: {dataset_name}")
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
- "dataset": dataset_name,
213
- "n_obs": int(adata.n_obs),
214
- "n_vars": int(adata.n_vars),
215
- "umap_shape": umap_shape,
216
- "obsm_keys": list(map(str, adata.obsm_keys())),
217
  }
218
  )
219
- except Exception as e:
220
- return _err(e)
221
 
222
 
223
  @mcp.tool(
224
- name="cluster_leiden",
225
- description="Run Leiden clustering after neighbors graph construction.",
226
  )
227
- def cluster_leiden(
228
- dataset_name: str = "pbmc3k",
229
  resolution: float = 1.0,
230
- n_neighbors: int = 10,
231
- n_pcs: int = 30,
232
- ) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
233
  """
234
- Perform Leiden clustering on a built-in dataset.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- Returns:
243
- Dict with success/result/error. result includes cluster labels and counts.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  """
245
  try:
246
- if not hasattr(sc.datasets, dataset_name):
247
- raise ValueError(f"Unknown dataset: {dataset_name}")
248
- adata = getattr(sc.datasets, dataset_name)()
249
-
250
- sc.pp.normalize_total(adata, target_sum=1e4)
251
- sc.pp.log1p(adata)
252
- sc.pp.highly_variable_genes(adata, n_top_genes=2000, inplace=True)
253
- if "highly_variable" in adata.var:
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
- "dataset": dataset_name,
264
- "resolution": resolution,
265
- "n_clusters": int(len(counts)),
266
- "cluster_counts": {str(k): int(v) for k, v in counts.items()},
267
  }
268
  )
269
- except Exception as e:
270
- return _err(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- fastapi
3
- uvicorn[standard]
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- project_root = os.path.dirname(os.path.abspath(__file__))
9
- mcp_plugin_dir = os.path.join(project_root, "mcp_plugin")
10
- if mcp_plugin_dir not in sys.path:
11
- sys.path.insert(0, mcp_plugin_dir)
12
 
13
  from mcp_service import create_app
14
 
15
- def main():
16
- """Start FastMCP service"""
 
 
 
 
 
 
 
 
17
  app = create_app()
18
- # Use environment variable to configure port, default 8000
19
- port = int(os.environ.get("MCP_PORT", "8000"))
20
-
21
- # Choose transport mode based on environment variable
22
- transport = os.environ.get("MCP_TRANSPORT", "stdio")
23
  if transport == "http":
24
  app.run(transport="http", host="0.0.0.0", port=port)
25
- else:
26
- # Default to STDIO mode
27
- app.run()
 
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()