strand-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- strand_mcp-0.1.0/.gitignore +9 -0
- strand_mcp-0.1.0/PKG-INFO +108 -0
- strand_mcp-0.1.0/README.md +79 -0
- strand_mcp-0.1.0/pyproject.toml +51 -0
- strand_mcp-0.1.0/src/strand_mcp/__init__.py +23 -0
- strand_mcp-0.1.0/src/strand_mcp/__main__.py +15 -0
- strand_mcp-0.1.0/src/strand_mcp/server.py +324 -0
- strand_mcp-0.1.0/tests/test_server.py +55 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: strand-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the Strand Platform — exposes upload / predict / job lifecycle to Claude, Cursor, and any other MCP-aware agent.
|
|
5
|
+
Project-URL: Homepage, https://strandai.com
|
|
6
|
+
Project-URL: Documentation, https://app.strandai.com/docs/api
|
|
7
|
+
Project-URL: Repository, https://github.com/Strand-AI/strand-official
|
|
8
|
+
Author-email: Strand AI <engineering@strandai.bio>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: anthropic,claude,h&e,mcp,pathology,spatial-omics
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: mcp>=1.0.0
|
|
24
|
+
Requires-Dist: strand-sdk>=0.1.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# strand-mcp
|
|
31
|
+
|
|
32
|
+
MCP server for the Strand Platform. Exposes upload / estimate / predict / job-lifecycle as MCP tools so any agent (Claude Code, Cursor, etc.) can run H&E → spatial-proteomics inference end-to-end.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install strand-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from this repo:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd sdks/mcp-server
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Auth
|
|
48
|
+
|
|
49
|
+
Generate an API key at <https://app.strandai.com/settings/api-keys> and export it:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
export STRAND_API_KEY=sk-strand-...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The server also reads `STRAND_BASE_URL` (defaults to `https://app.strandai.com`) if you need to point at staging.
|
|
56
|
+
|
|
57
|
+
## Wire into Claude Code
|
|
58
|
+
|
|
59
|
+
Add to `~/.claude/mcp.json` (or your project's `.mcp.json`):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"strand": {
|
|
65
|
+
"command": "strand-mcp",
|
|
66
|
+
"env": {
|
|
67
|
+
"STRAND_API_KEY": "sk-strand-..."
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then in Claude Code you can ask things like:
|
|
75
|
+
|
|
76
|
+
> Upload `~/slides/biopsy.svs` to Strand, run prediction for CD3, CD8, and Ki67, wait for the job, and download results into `~/results/`.
|
|
77
|
+
|
|
78
|
+
Claude calls the tools in sequence — no glue code needed.
|
|
79
|
+
|
|
80
|
+
## Tools
|
|
81
|
+
|
|
82
|
+
| Tool | Purpose |
|
|
83
|
+
| ------------------ | --------------------------------------------------------------------------- |
|
|
84
|
+
| `upload_slide` | Upload a local WSI; returns `upload_id` + dimensions |
|
|
85
|
+
| `estimate_run` | Patch count + credit cost for a `(upload_id, markers)` pair |
|
|
86
|
+
| `set_sample_mpp` | Set user-reported slide microns per pixel before inference |
|
|
87
|
+
| `submit_run` | Kick off a Lattice inference job; returns `job_id` |
|
|
88
|
+
| `get_job` | Point-in-time status snapshot |
|
|
89
|
+
| `cancel_job` | Cancel an in-flight job and refund its reserved credits |
|
|
90
|
+
| `wait_for_job` | Block until terminal state (uses SSE under the hood) |
|
|
91
|
+
| `download_results` | Pull OME-Zarr results into a local directory |
|
|
92
|
+
| `download_ome_tiff` | Render and download a completed job as a single OME-TIFF file |
|
|
93
|
+
|
|
94
|
+
## Smoke test
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
STRAND_API_KEY=sk-strand-... strand-mcp
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The server listens on stdio and waits for an MCP client to attach.
|
|
101
|
+
|
|
102
|
+
## Dev
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cd sdks/mcp-server
|
|
106
|
+
pip install -e ".[dev]" || pip install -e .
|
|
107
|
+
python -m strand_mcp
|
|
108
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# strand-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for the Strand Platform. Exposes upload / estimate / predict / job-lifecycle as MCP tools so any agent (Claude Code, Cursor, etc.) can run H&E → spatial-proteomics inference end-to-end.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install strand-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or from this repo:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd sdks/mcp-server
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Auth
|
|
19
|
+
|
|
20
|
+
Generate an API key at <https://app.strandai.com/settings/api-keys> and export it:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export STRAND_API_KEY=sk-strand-...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The server also reads `STRAND_BASE_URL` (defaults to `https://app.strandai.com`) if you need to point at staging.
|
|
27
|
+
|
|
28
|
+
## Wire into Claude Code
|
|
29
|
+
|
|
30
|
+
Add to `~/.claude/mcp.json` (or your project's `.mcp.json`):
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"strand": {
|
|
36
|
+
"command": "strand-mcp",
|
|
37
|
+
"env": {
|
|
38
|
+
"STRAND_API_KEY": "sk-strand-..."
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then in Claude Code you can ask things like:
|
|
46
|
+
|
|
47
|
+
> Upload `~/slides/biopsy.svs` to Strand, run prediction for CD3, CD8, and Ki67, wait for the job, and download results into `~/results/`.
|
|
48
|
+
|
|
49
|
+
Claude calls the tools in sequence — no glue code needed.
|
|
50
|
+
|
|
51
|
+
## Tools
|
|
52
|
+
|
|
53
|
+
| Tool | Purpose |
|
|
54
|
+
| ------------------ | --------------------------------------------------------------------------- |
|
|
55
|
+
| `upload_slide` | Upload a local WSI; returns `upload_id` + dimensions |
|
|
56
|
+
| `estimate_run` | Patch count + credit cost for a `(upload_id, markers)` pair |
|
|
57
|
+
| `set_sample_mpp` | Set user-reported slide microns per pixel before inference |
|
|
58
|
+
| `submit_run` | Kick off a Lattice inference job; returns `job_id` |
|
|
59
|
+
| `get_job` | Point-in-time status snapshot |
|
|
60
|
+
| `cancel_job` | Cancel an in-flight job and refund its reserved credits |
|
|
61
|
+
| `wait_for_job` | Block until terminal state (uses SSE under the hood) |
|
|
62
|
+
| `download_results` | Pull OME-Zarr results into a local directory |
|
|
63
|
+
| `download_ome_tiff` | Render and download a completed job as a single OME-TIFF file |
|
|
64
|
+
|
|
65
|
+
## Smoke test
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
STRAND_API_KEY=sk-strand-... strand-mcp
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The server listens on stdio and waits for an MCP client to attach.
|
|
72
|
+
|
|
73
|
+
## Dev
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
cd sdks/mcp-server
|
|
77
|
+
pip install -e ".[dev]" || pip install -e .
|
|
78
|
+
python -m strand_mcp
|
|
79
|
+
```
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "strand-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for the Strand Platform — exposes upload / predict / job lifecycle to Claude, Cursor, and any other MCP-aware agent."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Strand AI", email = "engineering@strandai.bio" }]
|
|
13
|
+
keywords = ["mcp", "anthropic", "claude", "spatial-omics", "h&e", "pathology"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"mcp>=1.0.0",
|
|
29
|
+
"strand-sdk>=0.1.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
strand-mcp = "strand_mcp.__main__:main"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://strandai.com"
|
|
40
|
+
Documentation = "https://app.strandai.com/docs/api"
|
|
41
|
+
Repository = "https://github.com/Strand-AI/strand-official"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/strand_mcp"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
target-version = "py310"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Strand Platform MCP server.
|
|
2
|
+
|
|
3
|
+
Exposes the Strand Python SDK as MCP tools so any MCP-aware agent
|
|
4
|
+
(Claude Code, Cursor, etc.) can run H&E → spatial-proteomics inference
|
|
5
|
+
end-to-end.
|
|
6
|
+
|
|
7
|
+
Tools:
|
|
8
|
+
- list_slides / list_runs inventory
|
|
9
|
+
- upload_slide(path) upload a local WSI
|
|
10
|
+
- estimate_run(upload_id, markers) cost/patch estimate
|
|
11
|
+
- submit_run(upload_id, markers) kick off a job
|
|
12
|
+
- get_job(job_id) status snapshot
|
|
13
|
+
- wait_for_job(job_id, timeout_s) block until terminal
|
|
14
|
+
- download_results(job_id, dest_dir) pull zarr results to disk
|
|
15
|
+
|
|
16
|
+
Auth is via STRAND_API_KEY env var (matches strand-sdk).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .server import build_server
|
|
22
|
+
|
|
23
|
+
__all__ = ["build_server"]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""MCP server implementation.
|
|
2
|
+
|
|
3
|
+
Stateless tools that wrap the Strand Python SDK. Each tool resolves the
|
|
4
|
+
SDK client lazily so server startup never fails when STRAND_API_KEY is
|
|
5
|
+
missing — the failure is surfaced when an actual tool runs.
|
|
6
|
+
|
|
7
|
+
Spelled-out tool schemas (no auto-generation from typing) so MCP clients
|
|
8
|
+
see clean field names + descriptions in the tool catalog.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import mcp.types as mcp_types
|
|
19
|
+
from mcp.server import Server
|
|
20
|
+
from mcp.server.stdio import stdio_server
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_client(): # type: ignore[no-untyped-def]
|
|
24
|
+
"""Lazy SDK client. Raises a tool-friendly error if creds are missing."""
|
|
25
|
+
|
|
26
|
+
api_key = os.environ.get("STRAND_API_KEY")
|
|
27
|
+
if not api_key:
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
"STRAND_API_KEY is not set. Generate one at https://app.strandai.com/settings/api-keys "
|
|
30
|
+
"and pass it via env to this MCP server."
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
from strand import Client # type: ignore[import-not-found]
|
|
34
|
+
except ImportError as e:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
"strand-sdk is not installed. `pip install strand-sdk` (or install this "
|
|
37
|
+
"package, which depends on it)."
|
|
38
|
+
) from e
|
|
39
|
+
base_url = os.environ.get("STRAND_BASE_URL") or "https://app.strandai.com"
|
|
40
|
+
return Client(api_key=api_key, base_url=base_url)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _json(obj: Any) -> list[mcp_types.TextContent]:
|
|
44
|
+
"""Serialize a dataclass / dict / primitive to a single TextContent.
|
|
45
|
+
|
|
46
|
+
Dataclasses get vars()'d; datetimes get iso-formatted; everything else
|
|
47
|
+
falls through to json's default (str()).
|
|
48
|
+
"""
|
|
49
|
+
from dataclasses import asdict, is_dataclass
|
|
50
|
+
from datetime import datetime
|
|
51
|
+
|
|
52
|
+
def encode(v: Any) -> Any:
|
|
53
|
+
if is_dataclass(v):
|
|
54
|
+
return asdict(v)
|
|
55
|
+
if isinstance(v, datetime):
|
|
56
|
+
return v.isoformat()
|
|
57
|
+
if isinstance(v, list):
|
|
58
|
+
return [encode(x) for x in v]
|
|
59
|
+
if isinstance(v, dict):
|
|
60
|
+
return {k: encode(x) for k, x in v.items()}
|
|
61
|
+
return v
|
|
62
|
+
|
|
63
|
+
return [mcp_types.TextContent(type="text", text=json.dumps(encode(obj), indent=2, default=str))]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
TOOLS: list[mcp_types.Tool] = [
|
|
67
|
+
mcp_types.Tool(
|
|
68
|
+
name="upload_slide",
|
|
69
|
+
description=(
|
|
70
|
+
"Upload a local H&E whole-slide image (TIFF / SVS / NDPI / PNG) to the Strand platform. "
|
|
71
|
+
"Returns the upload id, GCS path, and the slide's pixel dimensions once the dimension "
|
|
72
|
+
"pre-pass completes (width_px / height_px may be null for non-image WSIs while the "
|
|
73
|
+
"OpenSlide sidecar runs)."
|
|
74
|
+
),
|
|
75
|
+
inputSchema={
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"path": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"description": "Absolute path to the local WSI file.",
|
|
81
|
+
},
|
|
82
|
+
"content_type": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"description": "MIME type override. Defaults to image/tiff for unknown extensions.",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"required": ["path"],
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
mcp_types.Tool(
|
|
91
|
+
name="estimate_run",
|
|
92
|
+
description=(
|
|
93
|
+
"Estimate the credit cost + patch count for a prediction run on an uploaded slide. "
|
|
94
|
+
"Does NOT reserve credits."
|
|
95
|
+
),
|
|
96
|
+
inputSchema={
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"upload_id": {"type": "string"},
|
|
100
|
+
"markers": {
|
|
101
|
+
"type": "array",
|
|
102
|
+
"items": {"type": "string"},
|
|
103
|
+
"description": "Marker names to predict (e.g. ['CD3', 'CD8', 'Ki67']).",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
"required": ["upload_id", "markers"],
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
mcp_types.Tool(
|
|
110
|
+
name="set_sample_mpp",
|
|
111
|
+
description=(
|
|
112
|
+
"Set a slide's user-reported base-level microns per pixel before inference. "
|
|
113
|
+
"This value takes precedence over embedded slide metadata. Omit mpp_y for "
|
|
114
|
+
"isotropic pixels."
|
|
115
|
+
),
|
|
116
|
+
inputSchema={
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"sample_id": {"type": "string"},
|
|
120
|
+
"mpp_x": {
|
|
121
|
+
"type": "number",
|
|
122
|
+
"exclusiveMinimum": 0,
|
|
123
|
+
"maximum": 100,
|
|
124
|
+
},
|
|
125
|
+
"mpp_y": {
|
|
126
|
+
"type": "number",
|
|
127
|
+
"exclusiveMinimum": 0,
|
|
128
|
+
"maximum": 100,
|
|
129
|
+
"description": "Optional vertical microns per pixel. Defaults to mpp_x.",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
"required": ["sample_id", "mpp_x"],
|
|
133
|
+
},
|
|
134
|
+
),
|
|
135
|
+
mcp_types.Tool(
|
|
136
|
+
name="submit_run",
|
|
137
|
+
description=(
|
|
138
|
+
"Submit a prediction job: H&E → multiplex protein channels via Lattice. Returns the "
|
|
139
|
+
"job id; the job runs asynchronously. Credits are reserved atomically; this fails with "
|
|
140
|
+
"'insufficient_credits' if the org balance is short."
|
|
141
|
+
),
|
|
142
|
+
inputSchema={
|
|
143
|
+
"type": "object",
|
|
144
|
+
"properties": {
|
|
145
|
+
"upload_id": {"type": "string"},
|
|
146
|
+
"markers": {"type": "array", "items": {"type": "string"}},
|
|
147
|
+
},
|
|
148
|
+
"required": ["upload_id", "markers"],
|
|
149
|
+
},
|
|
150
|
+
),
|
|
151
|
+
mcp_types.Tool(
|
|
152
|
+
name="get_job",
|
|
153
|
+
description=(
|
|
154
|
+
"Point-in-time status snapshot for a job. Returns status, progress, markers, "
|
|
155
|
+
"timestamps, and error_message if failed."
|
|
156
|
+
),
|
|
157
|
+
inputSchema={
|
|
158
|
+
"type": "object",
|
|
159
|
+
"properties": {"job_id": {"type": "string"}},
|
|
160
|
+
"required": ["job_id"],
|
|
161
|
+
},
|
|
162
|
+
),
|
|
163
|
+
mcp_types.Tool(
|
|
164
|
+
name="cancel_job",
|
|
165
|
+
description=(
|
|
166
|
+
"Cancel an in-flight job, refund its reserved credits, and release the organization's "
|
|
167
|
+
"concurrent-job slot. Returns an error if the job is already terminal."
|
|
168
|
+
),
|
|
169
|
+
inputSchema={
|
|
170
|
+
"type": "object",
|
|
171
|
+
"properties": {"job_id": {"type": "string"}},
|
|
172
|
+
"required": ["job_id"],
|
|
173
|
+
},
|
|
174
|
+
),
|
|
175
|
+
mcp_types.Tool(
|
|
176
|
+
name="wait_for_job",
|
|
177
|
+
description=(
|
|
178
|
+
"Block until a job reaches a terminal state (completed | failed | cancelled). Uses the "
|
|
179
|
+
"SSE stream under the hood so updates arrive promptly. timeout_s defaults to 600 (10 min)."
|
|
180
|
+
),
|
|
181
|
+
inputSchema={
|
|
182
|
+
"type": "object",
|
|
183
|
+
"properties": {
|
|
184
|
+
"job_id": {"type": "string"},
|
|
185
|
+
"timeout_s": {"type": "number", "default": 600},
|
|
186
|
+
},
|
|
187
|
+
"required": ["job_id"],
|
|
188
|
+
},
|
|
189
|
+
),
|
|
190
|
+
mcp_types.Tool(
|
|
191
|
+
name="download_results",
|
|
192
|
+
description=(
|
|
193
|
+
"Download a completed job's OME-Zarr result tree to a local directory. Streams the "
|
|
194
|
+
"files via the API-key zarr proxy. Returns the local root path. Fails if the job is "
|
|
195
|
+
"not yet completed."
|
|
196
|
+
),
|
|
197
|
+
inputSchema={
|
|
198
|
+
"type": "object",
|
|
199
|
+
"properties": {
|
|
200
|
+
"job_id": {"type": "string"},
|
|
201
|
+
"dest_dir": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"description": "Local directory to write into. Created if it doesn't exist.",
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
"required": ["job_id", "dest_dir"],
|
|
207
|
+
},
|
|
208
|
+
),
|
|
209
|
+
mcp_types.Tool(
|
|
210
|
+
name="download_ome_tiff",
|
|
211
|
+
description=(
|
|
212
|
+
"Request an OME-TIFF export for a completed job, wait for rendering, and download "
|
|
213
|
+
"the file. Use download_results instead when you need the original OME-Zarr tree."
|
|
214
|
+
),
|
|
215
|
+
inputSchema={
|
|
216
|
+
"type": "object",
|
|
217
|
+
"properties": {
|
|
218
|
+
"job_id": {"type": "string"},
|
|
219
|
+
"path": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"description": "Local .ome.tiff destination path. Parent directories are created.",
|
|
222
|
+
},
|
|
223
|
+
"timeout_s": {
|
|
224
|
+
"type": "number",
|
|
225
|
+
"default": 1800,
|
|
226
|
+
"description": "Maximum seconds to wait for the export.",
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
"required": ["job_id", "path"],
|
|
230
|
+
},
|
|
231
|
+
),
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build_server() -> Server:
|
|
236
|
+
"""Construct the MCP Server with all Strand tools registered."""
|
|
237
|
+
|
|
238
|
+
server: Server = Server("strand-mcp")
|
|
239
|
+
|
|
240
|
+
@server.list_tools()
|
|
241
|
+
async def _list_tools() -> list[mcp_types.Tool]:
|
|
242
|
+
return TOOLS
|
|
243
|
+
|
|
244
|
+
@server.call_tool()
|
|
245
|
+
async def _call_tool(name: str, args: dict[str, Any]) -> list[mcp_types.TextContent]:
|
|
246
|
+
try:
|
|
247
|
+
return _dispatch(name, args)
|
|
248
|
+
except Exception as e: # noqa: BLE001 - surface tool failures as text content
|
|
249
|
+
return [mcp_types.TextContent(type="text", text=f"error: {type(e).__name__}: {e}")]
|
|
250
|
+
|
|
251
|
+
return server
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _dispatch(name: str, args: dict[str, Any]) -> list[mcp_types.TextContent]:
|
|
255
|
+
client = _get_client()
|
|
256
|
+
try:
|
|
257
|
+
if name == "upload_slide":
|
|
258
|
+
path = Path(args["path"]).expanduser().resolve()
|
|
259
|
+
if not path.exists():
|
|
260
|
+
raise FileNotFoundError(f"file not found: {path}")
|
|
261
|
+
upload = client.uploads.upload_file(
|
|
262
|
+
str(path), content_type=args.get("content_type")
|
|
263
|
+
)
|
|
264
|
+
return _json(upload)
|
|
265
|
+
|
|
266
|
+
if name == "estimate_run":
|
|
267
|
+
est = client.predict.estimate(args["upload_id"], args["markers"])
|
|
268
|
+
return _json(est)
|
|
269
|
+
|
|
270
|
+
if name == "set_sample_mpp":
|
|
271
|
+
result = client.samples.set_mpp(
|
|
272
|
+
args["sample_id"],
|
|
273
|
+
args["mpp_x"],
|
|
274
|
+
args.get("mpp_y"),
|
|
275
|
+
)
|
|
276
|
+
return _json(result)
|
|
277
|
+
|
|
278
|
+
if name == "submit_run":
|
|
279
|
+
job = client.predict.submit(args["upload_id"], args["markers"])
|
|
280
|
+
return _json({"job_id": job.id, "reserved_credits": job.reserved_credits})
|
|
281
|
+
|
|
282
|
+
if name == "get_job":
|
|
283
|
+
# client.jobs.get() already calls refresh() internally — return the
|
|
284
|
+
# cached status snapshot.
|
|
285
|
+
job = client.jobs.get(args["job_id"])
|
|
286
|
+
return _json(job.status)
|
|
287
|
+
|
|
288
|
+
if name == "cancel_job":
|
|
289
|
+
job = client.jobs.cancel(args["job_id"])
|
|
290
|
+
return _json(job.status)
|
|
291
|
+
|
|
292
|
+
if name == "wait_for_job":
|
|
293
|
+
job = client.jobs.get(args["job_id"])
|
|
294
|
+
final = job.wait(timeout=float(args.get("timeout_s", 600)))
|
|
295
|
+
return _json(final)
|
|
296
|
+
|
|
297
|
+
if name == "download_results":
|
|
298
|
+
job = client.jobs.get(args["job_id"])
|
|
299
|
+
dest = Path(args["dest_dir"]).expanduser().resolve()
|
|
300
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
# SDK signature is (path: str | None) → Path; pass str so positional
|
|
302
|
+
# type matches.
|
|
303
|
+
written = job.download_results(str(dest))
|
|
304
|
+
return _json({"path": str(written)})
|
|
305
|
+
|
|
306
|
+
if name == "download_ome_tiff":
|
|
307
|
+
job = client.jobs.get(args["job_id"])
|
|
308
|
+
path = Path(args["path"]).expanduser().resolve()
|
|
309
|
+
written = job.export_ome_tiff(
|
|
310
|
+
str(path),
|
|
311
|
+
timeout=float(args.get("timeout_s", 1800)),
|
|
312
|
+
)
|
|
313
|
+
return _json({"path": str(written), "format": "ome-tiff"})
|
|
314
|
+
|
|
315
|
+
raise ValueError(f"unknown tool: {name}")
|
|
316
|
+
finally:
|
|
317
|
+
client.close()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def run_stdio() -> None:
|
|
321
|
+
"""Entry point for `strand-mcp` (stdio transport)."""
|
|
322
|
+
server = build_server()
|
|
323
|
+
async with stdio_server() as (read, write):
|
|
324
|
+
await server.run(read, write, server.create_initialization_options())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import strand_mcp.server as server_module
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeSamples:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.calls: list[tuple[str, float, float | None]] = []
|
|
14
|
+
|
|
15
|
+
def set_mpp(self, sample_id: str, mpp_x: float, mpp_y: float | None) -> dict[str, Any]:
|
|
16
|
+
self.calls.append((sample_id, mpp_x, mpp_y))
|
|
17
|
+
return {
|
|
18
|
+
"id": sample_id,
|
|
19
|
+
"userMpp": {"x": mpp_x, "y": mpp_x if mpp_y is None else mpp_y},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeClient:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.samples = FakeSamples()
|
|
26
|
+
self.closed = False
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
self.closed = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_set_sample_mpp_tool_schema() -> None:
|
|
33
|
+
tool = next(item for item in server_module.TOOLS if item.name == "set_sample_mpp")
|
|
34
|
+
assert tool.input_schema["required"] == ["sample_id", "mpp_x"]
|
|
35
|
+
assert tool.input_schema["properties"]["mpp_x"]["exclusiveMinimum"] == 0
|
|
36
|
+
assert tool.input_schema["properties"]["mpp_x"]["maximum"] == 100
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize("mpp_y", [None, 0.25])
|
|
40
|
+
def test_dispatch_set_sample_mpp(
|
|
41
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
42
|
+
mpp_y: float | None,
|
|
43
|
+
) -> None:
|
|
44
|
+
client = FakeClient()
|
|
45
|
+
monkeypatch.setattr(server_module, "_get_client", lambda: client)
|
|
46
|
+
args: dict[str, Any] = {"sample_id": "sample-1", "mpp_x": 0.26}
|
|
47
|
+
if mpp_y is not None:
|
|
48
|
+
args["mpp_y"] = mpp_y
|
|
49
|
+
|
|
50
|
+
content = server_module._dispatch("set_sample_mpp", args)
|
|
51
|
+
|
|
52
|
+
assert client.samples.calls == [("sample-1", 0.26, mpp_y)]
|
|
53
|
+
assert client.closed is True
|
|
54
|
+
payload = json.loads(content[0].text)
|
|
55
|
+
assert payload["userMpp"] == {"x": 0.26, "y": 0.26 if mpp_y is None else mpp_y}
|