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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ .mypy_cache/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ __pycache__/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ uv.lock
@@ -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,15 @@
1
+ """CLI entry: `strand-mcp` runs the stdio MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from .server import run_stdio
8
+
9
+
10
+ def main() -> None:
11
+ asyncio.run(run_stdio())
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main()
@@ -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}