scigantic-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,17 @@
1
+ MIT No Attribution
2
+
3
+ Copyright Scigantic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do
10
+ so.
11
+
12
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
13
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
14
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
16
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
17
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: scigantic-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for the Scigantic catalog of public scientific data archives, with LLM-ready schema cards
5
+ Author: Scigantic
6
+ License: MIT-0
7
+ Project-URL: Homepage, https://scigantic.com
8
+ Project-URL: Documentation, https://scigantic.com/blog/scigantic-mcp-server
9
+ Keywords: mcp,model-context-protocol,datasets,open-data,schema-card,bioinformatics,earth-observation
10
+ Classifier: License :: OSI Approved :: MIT No Attribution License (MIT-0)
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: mcp<3,>=2
18
+ Requires-Dist: httpx>=0.27
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7; extra == "test"
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # scigantic-mcp
25
+
26
+ A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes the
27
+ **Scigantic catalog of public scientific data archives** to any MCP client.
28
+
29
+ It is built to drop into **[Kiro for Life Sciences](https://github.com/aws-samples/sample-kiro-power-life-sciences)**
30
+ alongside its domain database servers. Where each Kiro server wraps one domain's
31
+ APIs (genomics, proteomics, structural, …), Scigantic is the **cross-domain
32
+ launchpad**: 5,000+ curated public archives spanning every domain, each with an
33
+ LLM-ready **schema card** (file format, columns, sample rows/headers, inlined
34
+ READMEs/data dictionaries, and a copy-paste starter cell) so an agent can
35
+ understand a dataset's structure *before* downloading anything.
36
+
37
+ It is **discovery-only and zero-config** — every tool calls public, read-only
38
+ Scigantic endpoints, so there is no API key to set up.
39
+
40
+ Requires Python 3.10+ and MCP SDK 2.x.
41
+
42
+ > **Prefer the hosted server if your client speaks HTTP.** Scigantic also runs a
43
+ > remote MCP server at `https://api.scigantic.com/mcp` (no auth, nothing to
44
+ > install, and it carries two extra tools):
45
+ > `claude mcp add --transport http scigantic https://api.scigantic.com/mcp`.
46
+ > This package exists for clients that launch **stdio** servers, such as Kiro.
47
+
48
+ ## Tools
49
+
50
+ | Tool | What it does |
51
+ |------|--------------|
52
+ | `search_archives(query, category?, limit?)` | Natural-language search across the whole catalog. |
53
+ | `get_archive(id)` | Full metadata for one archive. |
54
+ | `get_schema_card(id)` | The compact schema card — the fastest way to learn a dataset's structure. |
55
+ | `get_data_access(id, language?)` | How to load the dataset in **your own** environment: storage location + copy-paste code snippets. |
56
+ | `list_archive_files(id, limit?)` | A sample of the files/objects in the archive. |
57
+
58
+ ## Prompts (guided workflows)
59
+
60
+ These surface as slash commands in Claude Code (`/mcp__scigantic__<name>`):
61
+
62
+ | Prompt | What it does |
63
+ |--------|--------------|
64
+ | `explore_dataset(topic)` | Search → inspect schema cards → recommend the best dataset → offer load code. |
65
+ | `start_analysis(archive_id, goal?)` | Pull schema card + data-access snippet for an archive and outline an analysis plan. |
66
+
67
+ ## Install & register in Kiro
68
+
69
+ Add an entry under `mcpServers` in `~/.kiro/settings/mcp.json`.
70
+
71
+ **Option A — `uvx` (zero-install, recommended):**
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "scigantic": {
77
+ "command": "uvx",
78
+ "args": ["scigantic-mcp"]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ **Option B — install into a venv (matches the Kiro servers' own mcp.json form):**
85
+
86
+ ```bash
87
+ python3 -m venv .venv && . .venv/bin/activate
88
+ pip install scigantic-mcp # or: pip install /path/to/scigantic-mcp
89
+ ```
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "scigantic": {
95
+ "command": "/path/to/.venv/bin/scigantic-mcp",
96
+ "env": {
97
+ "SCIGANTIC_API_URL": "https://api.scigantic.com"
98
+ }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Works the same in Claude Desktop / Claude Code (`claude mcp add scigantic -- uvx scigantic-mcp`)
105
+ or any MCP client that launches stdio servers.
106
+
107
+ ## Configuration
108
+
109
+ | Env var | Default | Purpose |
110
+ |---------|---------|---------|
111
+ | `SCIGANTIC_API_URL` | `https://api.scigantic.com` | API base (set to `https://staging-api.scigantic.com` for staging). |
112
+ | `SCIGANTIC_API_ORIGIN` | `https://scigantic.com` | Origin header → selects the public (default) catalog tenant. |
113
+
114
+ ## Develop & test
115
+
116
+ The tool/client layer has no `mcp` dependency, so those tests run on any Python
117
+ with `httpx` and need no network (mocked transport):
118
+
119
+ ```bash
120
+ python3 tests/test_tools.py # or: pytest
121
+ ```
122
+
123
+ `tests/test_server_import.py` covers the wiring layer — that the server module
124
+ imports, and that the registered tools and prompts are the expected set. It needs
125
+ the `mcp` SDK installed (Python ≥ 3.10) and is skipped otherwise:
126
+
127
+ ```bash
128
+ pip install -e '.[test]' && pytest
129
+ ```
130
+
131
+ Keep it that way: the tool tests skip `server.py` on purpose, so an SDK breaking
132
+ change is invisible to them. All 12 passed while the server could not import at
133
+ all under SDK 2.x, which is what `test_server_import.py` now guards against.
134
+
135
+ ## Roadmap
136
+
137
+ - **Richer discovery for agents** — structured tool outputs and MCP *resources*
138
+ (attach an archive + its schema card as durable context).
139
+ - Upstream inclusion as `life-sciences-scigantic` in `aws-samples/sample-kiro-power-life-sciences`
140
+ (see `docs/kiro-upstream-pr.md`).
141
+ - **Hosted compute is intentionally not exposed here.** Scigantic's notebooks are
142
+ interactive (a JupyterLab URL a human opens); handing an external agent that URL
143
+ is a dead end. The agent-to-agent path is `get_data_access` — the caller runs the
144
+ analysis in its own environment. Letting Scigantic *execute code for* an agent
145
+ (run against an ephemeral kernel with the dataset mounted, return outputs) is a
146
+ separate capability the platform would need to build first.
147
+
148
+ ## License
149
+
150
+ MIT-0.
@@ -0,0 +1,127 @@
1
+ # scigantic-mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes the
4
+ **Scigantic catalog of public scientific data archives** to any MCP client.
5
+
6
+ It is built to drop into **[Kiro for Life Sciences](https://github.com/aws-samples/sample-kiro-power-life-sciences)**
7
+ alongside its domain database servers. Where each Kiro server wraps one domain's
8
+ APIs (genomics, proteomics, structural, …), Scigantic is the **cross-domain
9
+ launchpad**: 5,000+ curated public archives spanning every domain, each with an
10
+ LLM-ready **schema card** (file format, columns, sample rows/headers, inlined
11
+ READMEs/data dictionaries, and a copy-paste starter cell) so an agent can
12
+ understand a dataset's structure *before* downloading anything.
13
+
14
+ It is **discovery-only and zero-config** — every tool calls public, read-only
15
+ Scigantic endpoints, so there is no API key to set up.
16
+
17
+ Requires Python 3.10+ and MCP SDK 2.x.
18
+
19
+ > **Prefer the hosted server if your client speaks HTTP.** Scigantic also runs a
20
+ > remote MCP server at `https://api.scigantic.com/mcp` (no auth, nothing to
21
+ > install, and it carries two extra tools):
22
+ > `claude mcp add --transport http scigantic https://api.scigantic.com/mcp`.
23
+ > This package exists for clients that launch **stdio** servers, such as Kiro.
24
+
25
+ ## Tools
26
+
27
+ | Tool | What it does |
28
+ |------|--------------|
29
+ | `search_archives(query, category?, limit?)` | Natural-language search across the whole catalog. |
30
+ | `get_archive(id)` | Full metadata for one archive. |
31
+ | `get_schema_card(id)` | The compact schema card — the fastest way to learn a dataset's structure. |
32
+ | `get_data_access(id, language?)` | How to load the dataset in **your own** environment: storage location + copy-paste code snippets. |
33
+ | `list_archive_files(id, limit?)` | A sample of the files/objects in the archive. |
34
+
35
+ ## Prompts (guided workflows)
36
+
37
+ These surface as slash commands in Claude Code (`/mcp__scigantic__<name>`):
38
+
39
+ | Prompt | What it does |
40
+ |--------|--------------|
41
+ | `explore_dataset(topic)` | Search → inspect schema cards → recommend the best dataset → offer load code. |
42
+ | `start_analysis(archive_id, goal?)` | Pull schema card + data-access snippet for an archive and outline an analysis plan. |
43
+
44
+ ## Install & register in Kiro
45
+
46
+ Add an entry under `mcpServers` in `~/.kiro/settings/mcp.json`.
47
+
48
+ **Option A — `uvx` (zero-install, recommended):**
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "scigantic": {
54
+ "command": "uvx",
55
+ "args": ["scigantic-mcp"]
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ **Option B — install into a venv (matches the Kiro servers' own mcp.json form):**
62
+
63
+ ```bash
64
+ python3 -m venv .venv && . .venv/bin/activate
65
+ pip install scigantic-mcp # or: pip install /path/to/scigantic-mcp
66
+ ```
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "scigantic": {
72
+ "command": "/path/to/.venv/bin/scigantic-mcp",
73
+ "env": {
74
+ "SCIGANTIC_API_URL": "https://api.scigantic.com"
75
+ }
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Works the same in Claude Desktop / Claude Code (`claude mcp add scigantic -- uvx scigantic-mcp`)
82
+ or any MCP client that launches stdio servers.
83
+
84
+ ## Configuration
85
+
86
+ | Env var | Default | Purpose |
87
+ |---------|---------|---------|
88
+ | `SCIGANTIC_API_URL` | `https://api.scigantic.com` | API base (set to `https://staging-api.scigantic.com` for staging). |
89
+ | `SCIGANTIC_API_ORIGIN` | `https://scigantic.com` | Origin header → selects the public (default) catalog tenant. |
90
+
91
+ ## Develop & test
92
+
93
+ The tool/client layer has no `mcp` dependency, so those tests run on any Python
94
+ with `httpx` and need no network (mocked transport):
95
+
96
+ ```bash
97
+ python3 tests/test_tools.py # or: pytest
98
+ ```
99
+
100
+ `tests/test_server_import.py` covers the wiring layer — that the server module
101
+ imports, and that the registered tools and prompts are the expected set. It needs
102
+ the `mcp` SDK installed (Python ≥ 3.10) and is skipped otherwise:
103
+
104
+ ```bash
105
+ pip install -e '.[test]' && pytest
106
+ ```
107
+
108
+ Keep it that way: the tool tests skip `server.py` on purpose, so an SDK breaking
109
+ change is invisible to them. All 12 passed while the server could not import at
110
+ all under SDK 2.x, which is what `test_server_import.py` now guards against.
111
+
112
+ ## Roadmap
113
+
114
+ - **Richer discovery for agents** — structured tool outputs and MCP *resources*
115
+ (attach an archive + its schema card as durable context).
116
+ - Upstream inclusion as `life-sciences-scigantic` in `aws-samples/sample-kiro-power-life-sciences`
117
+ (see `docs/kiro-upstream-pr.md`).
118
+ - **Hosted compute is intentionally not exposed here.** Scigantic's notebooks are
119
+ interactive (a JupyterLab URL a human opens); handing an external agent that URL
120
+ is a dead end. The agent-to-agent path is `get_data_access` — the caller runs the
121
+ analysis in its own environment. Letting Scigantic *execute code for* an agent
122
+ (run against an ephemeral kernel with the dataset mounted, return outputs) is a
123
+ separate capability the platform would need to build first.
124
+
125
+ ## License
126
+
127
+ MIT-0.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scigantic-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for the Scigantic catalog of public scientific data archives, with LLM-ready schema cards"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT-0" }
12
+ authors = [{ name = "Scigantic" }]
13
+ keywords = ["mcp", "model-context-protocol", "datasets", "open-data", "schema-card", "bioinformatics", "earth-observation"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT No Attribution License (MIT-0)",
16
+ "Programming Language :: Python :: 3",
17
+ "Intended Audience :: Science/Research",
18
+ "Topic :: Scientific/Engineering",
19
+ ]
20
+
21
+ # mcp 2.0 removed `mcp.server.fastmcp`, so an open-ended floor silently installs
22
+ # an SDK this package cannot import. Both bounds are load-bearing.
23
+ dependencies = [
24
+ "mcp>=2,<3",
25
+ "httpx>=0.27",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://scigantic.com"
30
+ Documentation = "https://scigantic.com/blog/scigantic-mcp-server"
31
+
32
+ [project.optional-dependencies]
33
+ test = ["pytest>=7", "pytest-asyncio>=0.23"]
34
+
35
+ # Console-script entry point. This is the binary Kiro launches from mcp.json
36
+ # (e.g. "command": "/path/to/.venv/bin/scigantic-mcp") and what `uvx scigantic-mcp`
37
+ # resolves to.
38
+ [project.scripts]
39
+ scigantic-mcp = "scigantic_mcp.server:main"
40
+
41
+ [tool.setuptools]
42
+ packages = ["scigantic_mcp"]
@@ -0,0 +1,9 @@
1
+ """Scigantic MCP server — public scientific-data catalog as MCP tools.
2
+
3
+ Built to drop into Kiro for Life Sciences (and any MCP client) alongside the
4
+ domain database servers. Where those servers each wrap one domain's APIs,
5
+ Scigantic is the cross-domain launchpad: 5,000+ curated public archives with
6
+ LLM-ready schema cards, searchable in one place.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,124 @@
1
+ """Async HTTP client for the public Scigantic API.
2
+
3
+ Deliberately mirrors the shape of Kiro's `life-sciences-common` base
4
+ (async httpx client, exponential backoff on 429/5xx/timeout, a small error
5
+ taxonomy) so this connector can either run standalone (self-serve install) or
6
+ be adapted to inherit `BaseLifeSciencesServer` for the aws-samples monorepo.
7
+
8
+ Only public, unauthenticated, read-only endpoints are used here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ from typing import Any, Dict, Optional
16
+
17
+ import httpx
18
+
19
+ DEFAULT_BASE_URL = "https://api.scigantic.com"
20
+ # The API derives its (default) tenant from the request Origin; pinning it to
21
+ # scigantic.com keeps this connector on the main public catalog.
22
+ DEFAULT_ORIGIN = "https://scigantic.com"
23
+ USER_AGENT = "scigantic-mcp/0.1 (+https://scigantic.com)"
24
+
25
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
26
+
27
+
28
+ class ScigateApiError(Exception):
29
+ """Base class for Scigantic API errors."""
30
+
31
+
32
+ class RateLimitError(ScigateApiError):
33
+ """429 Too Many Requests after retries were exhausted."""
34
+
35
+
36
+ class AuthenticationError(ScigateApiError):
37
+ """401/403 — should not happen on public endpoints, surfaced for clarity."""
38
+
39
+
40
+ class NotFoundError(ScigateApiError):
41
+ """404 — archive/paper/resource does not exist."""
42
+
43
+
44
+ class ScigateClient:
45
+ """Thin retrying JSON client over the public Scigantic REST API."""
46
+
47
+ def __init__(
48
+ self,
49
+ base_url: Optional[str] = None,
50
+ origin: Optional[str] = None,
51
+ http_client: Optional[httpx.AsyncClient] = None,
52
+ max_retries: int = 3,
53
+ timeout: float = 20.0,
54
+ ) -> None:
55
+ self.base_url = (base_url or os.environ.get("SCIGANTIC_API_URL") or DEFAULT_BASE_URL).rstrip("/")
56
+ self.origin = origin or os.environ.get("SCIGANTIC_API_ORIGIN") or DEFAULT_ORIGIN
57
+ self.max_retries = max_retries
58
+ self._timeout = timeout
59
+ # Allow an injected client (tests use httpx.MockTransport); otherwise lazily build one.
60
+ self._client = http_client
61
+ self._owns_client = http_client is None
62
+
63
+ def _headers(self) -> Dict[str, str]:
64
+ return {"Origin": self.origin, "User-Agent": USER_AGENT, "Accept": "application/json"}
65
+
66
+ async def _ensure_client(self) -> httpx.AsyncClient:
67
+ if self._client is None:
68
+ self._client = httpx.AsyncClient(base_url=self.base_url, timeout=self._timeout)
69
+ return self._client
70
+
71
+ async def aclose(self) -> None:
72
+ if self._client is not None and self._owns_client:
73
+ await self._client.aclose()
74
+ self._client = None
75
+
76
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
77
+ client = await self._ensure_client()
78
+ last_exc: Optional[Exception] = None
79
+ for attempt in range(self.max_retries + 1):
80
+ try:
81
+ resp = await client.request(method, path, headers=self._headers(), **kwargs)
82
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
83
+ last_exc = exc
84
+ if attempt < self.max_retries:
85
+ await asyncio.sleep(self._backoff(attempt))
86
+ continue
87
+ raise ScigateApiError(f"{method} {path} failed: {exc}") from exc
88
+
89
+ if resp.status_code in _RETRYABLE_STATUS and attempt < self.max_retries:
90
+ await asyncio.sleep(self._backoff(attempt))
91
+ continue
92
+
93
+ return self._handle_response(method, path, resp)
94
+
95
+ # Only reached if every attempt raised a transport error.
96
+ raise ScigateApiError(f"{method} {path} failed after retries: {last_exc}")
97
+
98
+ @staticmethod
99
+ def _backoff(attempt: int) -> float:
100
+ # 0.5s, 1s, 2s — capped exponential backoff.
101
+ return min(0.5 * (2 ** attempt), 4.0)
102
+
103
+ @staticmethod
104
+ def _handle_response(method: str, path: str, resp: httpx.Response) -> Any:
105
+ status = resp.status_code
106
+ if status == 404:
107
+ raise NotFoundError(f"{path} not found")
108
+ if status in (401, 403):
109
+ raise AuthenticationError(f"{path} requires authentication ({status})")
110
+ if status == 429:
111
+ raise RateLimitError(f"{path} rate-limited (429)")
112
+ if status >= 400:
113
+ raise ScigateApiError(f"{method} {path} -> HTTP {status}: {resp.text[:200]}")
114
+ try:
115
+ return resp.json()
116
+ except ValueError as exc:
117
+ raise ScigateApiError(f"{path} returned non-JSON body") from exc
118
+
119
+ async def get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
120
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
121
+ return await self._request("GET", path, params=clean)
122
+
123
+ async def post_json(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
124
+ return await self._request("POST", path, json=body or {})
@@ -0,0 +1,150 @@
1
+ """Stdio MCP server exposing the Scigantic catalog to MCP clients (Kiro, etc.).
2
+
3
+ Thin wiring layer: each tool delegates to scigantic_mcp.tools (which is what the
4
+ tests exercise). Run via the `scigantic-mcp` console script or `uvx scigantic-mcp`.
5
+
6
+ Built on `MCPServer` from the MCP Python SDK 2.x. SDK 1.x's `FastMCP` was removed
7
+ in 2.0 (`mcp.server.fastmcp` no longer exists), which is why pyproject pins
8
+ `mcp>=2,<3` rather than an open-ended floor.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ from mcp.server import MCPServer
16
+
17
+ from . import __version__, tools
18
+ from .client import ScigateClient
19
+
20
+ mcp = MCPServer(
21
+ "scigantic",
22
+ title="Scigantic",
23
+ version=__version__,
24
+ website_url="https://scigantic.com",
25
+ instructions=(
26
+ "Scigantic is a cross-domain catalog of public scientific data archives. "
27
+ "Unlike single-domain database servers, it spans genomics, proteomics, structural "
28
+ "biology, imaging, earth observation and more, and ships an LLM-ready 'schema card' "
29
+ "for each dataset. Use search_archives to find datasets across domains, then "
30
+ "get_schema_card to understand structure before analysis."
31
+ ),
32
+ )
33
+
34
+ # Single shared client for the process lifetime (stdio server = one process).
35
+ _client: Optional[ScigateClient] = None
36
+
37
+
38
+ def _get_client() -> ScigateClient:
39
+ global _client
40
+ if _client is None:
41
+ _client = ScigateClient()
42
+ return _client
43
+
44
+
45
+ @mcp.tool()
46
+ async def search_archives(query: str, category: Optional[str] = None, limit: int = 10) -> str:
47
+ """Search the Scigantic catalog of public scientific data archives by natural-language query.
48
+
49
+ Returns ranked matches with id, title, category and a short summary across all domains.
50
+ Follow up with get_schema_card to understand a match's structure.
51
+
52
+ Args:
53
+ query: Natural-language search, e.g. "single-cell RNA-seq of human cortex".
54
+ category: Optional category filter, e.g. "Genomics & Bioinformatics" (comma-separate for several).
55
+ limit: Max results (default 10, max 50).
56
+ """
57
+ return await tools.search_archives(_get_client(), query=query, category=category, limit=limit)
58
+
59
+
60
+ @mcp.tool()
61
+ async def get_archive(id: str) -> str:
62
+ """Get full metadata for one archive by id, including whether a schema card exists.
63
+
64
+ Args:
65
+ id: Archive id from search_archives.
66
+ """
67
+ return await tools.get_archive(_get_client(), id=id)
68
+
69
+
70
+ @mcp.tool()
71
+ async def get_schema_card(id: str) -> str:
72
+ """Get the compact schema card for an archive: file format, columns, sample rows/headers,
73
+ sidecar docs (READMEs/data dictionaries) and a copy-paste starter cell.
74
+
75
+ This is the fastest way to understand a dataset's structure without downloading it.
76
+
77
+ Args:
78
+ id: Archive id from search_archives.
79
+ """
80
+ return await tools.get_schema_card(_get_client(), id=id)
81
+
82
+
83
+ @mcp.tool()
84
+ async def list_archive_files(id: str, limit: int = 50) -> str:
85
+ """List a sample of the files/objects in an archive's storage.
86
+
87
+ Args:
88
+ id: Archive id from search_archives.
89
+ limit: Max entries (default 50, max 200).
90
+ """
91
+ return await tools.list_archive_files(_get_client(), id=id, limit=limit)
92
+
93
+
94
+ @mcp.tool()
95
+ async def get_data_access(id: str, language: Optional[str] = None) -> str:
96
+ """Get how to load a dataset in YOUR OWN environment: storage location plus copy-paste
97
+ code snippets (from the dataset's schema card, or generated as a fallback).
98
+
99
+ Use this after finding a dataset when you want to run analysis where you are, rather
100
+ than in a hosted notebook.
101
+
102
+ Args:
103
+ id: Archive id from search_archives.
104
+ language: Optional filter for the snippet language (e.g. "datasets", "s3fs", "gcsfs").
105
+ """
106
+ return await tools.get_data_access(_get_client(), id=id, language=language)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Prompts — guided workflows. In Claude Code these surface as slash commands
111
+ # (/mcp__scigantic__explore_dataset, /mcp__scigantic__start_analysis).
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ @mcp.prompt()
116
+ def explore_dataset(topic: str) -> str:
117
+ """Find and understand a Scigantic dataset on a topic, end to end."""
118
+ return (
119
+ f"Find and understand a Scigantic dataset about: {topic}\n\n"
120
+ f'1. Use search_archives to find candidate datasets for "{topic}".\n'
121
+ "2. For the 1-2 most relevant results, call get_schema_card to understand structure "
122
+ "(format, columns, sample rows/headers, sidecar docs).\n"
123
+ "3. Summarize what's available and recommend the best fit, citing its archive id.\n"
124
+ "4. If I want to use it, call get_data_access to give me copy-paste code to load it in this environment."
125
+ )
126
+
127
+
128
+ @mcp.prompt()
129
+ def start_analysis(archive_id: str, goal: str = "") -> str:
130
+ """Set up analysis of a specific Scigantic archive in your own environment."""
131
+ goal = (goal or "").strip()
132
+ return (
133
+ f"Help me start analyzing the Scigantic archive {archive_id}."
134
+ + (f" My goal: {goal}." if goal else "")
135
+ + "\n\n"
136
+ f"1. Call get_archive and get_schema_card for {archive_id} to understand the dataset.\n"
137
+ "2. Call get_data_access to get the code to load it in this environment.\n"
138
+ "3. Propose a concrete analysis plan and the initial code to load and explore the data"
139
+ + (f", working toward: {goal}" if goal else "")
140
+ + "."
141
+ )
142
+
143
+
144
+ def main() -> None:
145
+ """Console-script entry point — runs the MCP server over stdio."""
146
+ mcp.run()
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
@@ -0,0 +1,209 @@
1
+ """Tool implementations for the Scigantic MCP server.
2
+
3
+ Pure async functions that take a ScigateClient and return formatted text. Kept
4
+ free of any `mcp` import so they can be unit-tested under any Python with a
5
+ mocked httpx transport; server.py wires them into the MCP server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .client import NotFoundError, ScigateApiError, ScigateClient
14
+
15
+
16
+ def _source_url(a: Dict[str, Any]) -> str:
17
+ return a.get("sourceUrl") or a.get("websiteUrl") or a.get("huggingFaceUrl") or "n/a"
18
+
19
+
20
+ def _summary(a: Dict[str, Any]) -> str:
21
+ return (a.get("summary") or a.get("description") or "").strip()
22
+
23
+
24
+ async def search_archives(
25
+ client: ScigateClient,
26
+ query: str,
27
+ category: Optional[str] = None,
28
+ limit: int = 10,
29
+ ) -> str:
30
+ query = (query or "").strip()
31
+ if not query:
32
+ return "Error: query is required."
33
+ limit = max(1, min(int(limit or 10), 50))
34
+ try:
35
+ resp = await client.get_json(
36
+ "/api/archives", {"search": query, "category": category, "limit": str(limit)}
37
+ )
38
+ except ScigateApiError as exc:
39
+ return f"Search failed: {exc}"
40
+
41
+ data: List[Dict[str, Any]] = resp.get("data") or []
42
+ rows = data[:limit]
43
+ if not rows:
44
+ return f'No archives matched "{query}".'
45
+
46
+ lines = [f"Found {len(rows)} archive(s) for \"{query}\":\n"]
47
+ for i, a in enumerate(rows, 1):
48
+ lines.append(
49
+ f"{i}. {a.get('title', 'Untitled')} [id: {a.get('id')}]\n"
50
+ f" {a.get('category') or 'Uncategorized'}"
51
+ f"{' · ' + a['cloudProvider'] if a.get('cloudProvider') else ''}\n"
52
+ f" {_summary(a)[:200]}\n"
53
+ f" source: {_source_url(a)}"
54
+ )
55
+ lines.append("\nNext: call get_schema_card(id) to see a dataset's structure before using it.")
56
+ return "\n".join(lines)
57
+
58
+
59
+ async def get_archive(client: ScigateClient, id: str) -> str:
60
+ id = (id or "").strip()
61
+ if not id:
62
+ return "Error: id is required."
63
+ try:
64
+ resp = await client.get_json(f"/api/archives/{id}")
65
+ except NotFoundError:
66
+ return f"Archive {id} not found."
67
+ except ScigateApiError as exc:
68
+ return f"Could not fetch archive {id}: {exc}"
69
+
70
+ a = resp.get("data") or {}
71
+ has_card = bool(a.get("schemaCard"))
72
+ storage = a.get("cloudProvider") or "?"
73
+ if a.get("bucketName"):
74
+ storage += " · " + a["bucketName"] + ("/" + a["bucketPrefix"] if a.get("bucketPrefix") else "")
75
+ return (
76
+ f"{a.get('title', 'Untitled')} [id: {a.get('id')}]\n"
77
+ f"Category: {a.get('category') or 'Uncategorized'}\n"
78
+ f"License: {a.get('license') or 'n/a'}\n"
79
+ f"Storage: {storage}\n"
80
+ f"Source: {_source_url(a)}\n\n"
81
+ f"{a.get('description') or _summary(a)}\n"
82
+ + ("\n(A schema card is available — call get_schema_card for structure details.)" if has_card else "")
83
+ )
84
+
85
+
86
+ async def get_schema_card(client: ScigateClient, id: str) -> str:
87
+ id = (id or "").strip()
88
+ if not id:
89
+ return "Error: id is required."
90
+ try:
91
+ resp = await client.get_json(f"/api/archives/{id}")
92
+ except NotFoundError:
93
+ return f"Archive {id} not found."
94
+ except ScigateApiError as exc:
95
+ return f"Could not fetch archive {id}: {exc}"
96
+
97
+ a = resp.get("data") or {}
98
+ card = a.get("schemaCard")
99
+ if not card:
100
+ return f"No schema card has been generated for \"{a.get('title') or id}\" yet."
101
+ return (
102
+ f"Schema card for {a.get('title')} [id: {id}] — file format, columns, "
103
+ f"sample rows/headers, sidecar docs, and a starter cell:\n\n"
104
+ + json.dumps(card, indent=2, ensure_ascii=False)
105
+ )
106
+
107
+
108
+ async def list_archive_files(client: ScigateClient, id: str, limit: int = 50) -> str:
109
+ id = (id or "").strip()
110
+ if not id:
111
+ return "Error: id is required."
112
+ limit = max(1, min(int(limit or 50), 200))
113
+ try:
114
+ resp = await client.get_json(f"/api/archives/{id}/files")
115
+ except NotFoundError:
116
+ return f"Archive {id} not found."
117
+ except ScigateApiError as exc:
118
+ return f"Could not list files for {id}: {exc}"
119
+
120
+ data = resp.get("data") or {}
121
+ files = data.get("files") if isinstance(data, dict) else data
122
+ files = files or []
123
+ shown = files[:limit]
124
+ if not shown:
125
+ return "No files listed (the bucket may be private, empty, or list-restricted)."
126
+
127
+ def fmt(f: Any) -> str:
128
+ if isinstance(f, str):
129
+ return f
130
+ name = f.get("name") or f.get("key") or "?"
131
+ return f"{name}" + (f" ({f['size']} bytes)" if f.get("size") is not None else "")
132
+
133
+ header = f"{len(shown)} file(s)" + (" (truncated)" if len(files) > len(shown) else "") + ":\n"
134
+ return header + "\n".join(fmt(f) for f in shown)
135
+
136
+
137
+ def _fallback_snippet(a: Dict[str, Any]) -> str:
138
+ """Best-effort load snippet for archives without a schema card's accessHints."""
139
+ provider = (a.get("cloudProvider") or "").lower()
140
+ bucket = a.get("bucketName") or ""
141
+ prefix = a.get("bucketPrefix") or ""
142
+ region = a.get("region") or ""
143
+ public = (a.get("bucketAccessType") or "") == "public"
144
+ path = bucket + (("/" + prefix) if prefix else "")
145
+ if provider in ("huggingface", "hf"):
146
+ return f'from datasets import load_dataset\nds = load_dataset("{bucket}")'
147
+ if provider in ("gcp", "gcs"):
148
+ tok = "token='anon'" if public else "token='google_default'"
149
+ return f"import gcsfs\nfs = gcsfs.GCSFileSystem({tok})\nfs.ls('{path}')[:20]"
150
+ if provider in ("aws", "s3"):
151
+ anon = "anon=True" if public else "anon=False"
152
+ note = f" # region: {region}" if region else ""
153
+ return f"import s3fs\nfs = s3fs.S3FileSystem({anon}){note}\nfs.ls('{path}')[:20]"
154
+ if provider in ("api", "postgres"):
155
+ return f"# Accessed via {provider}; see source: {_source_url(a)}"
156
+ return f"# See source for access details: {_source_url(a)}"
157
+
158
+
159
+ async def get_data_access(client: ScigateClient, id: str, language: Optional[str] = None) -> str:
160
+ """Return how to load this dataset in *your own* environment: storage location plus
161
+ copy-paste code snippets (from the dataset's schema card, or generated as a fallback).
162
+ """
163
+ id = (id or "").strip()
164
+ if not id:
165
+ return "Error: id is required."
166
+ try:
167
+ resp = await client.get_json(f"/api/archives/{id}")
168
+ except NotFoundError:
169
+ return f"Archive {id} not found."
170
+ except ScigateApiError as exc:
171
+ return f"Could not fetch archive {id}: {exc}"
172
+
173
+ a = resp.get("data") or {}
174
+ card = a.get("schemaCard") or {}
175
+ hints: List[Dict[str, Any]] = card.get("accessHints") or []
176
+ if language:
177
+ lang = language.lower()
178
+ hints = [h for h in hints if lang in (h.get("language") or "").lower()] or hints
179
+
180
+ storage = a.get("cloudProvider") or "?"
181
+ if a.get("bucketName"):
182
+ storage += " · " + a["bucketName"] + ("/" + a["bucketPrefix"] if a.get("bucketPrefix") else "")
183
+ if a.get("region"):
184
+ storage += f" (region: {a['region']})"
185
+
186
+ out = [
187
+ f"How to access \"{a.get('title') or id}\" in your own environment:\n",
188
+ f"Storage: {storage}",
189
+ f"Access: {a.get('bucketAccessType') or 'unknown'}",
190
+ f"Source: {_source_url(a)}",
191
+ "",
192
+ "Load it:",
193
+ ]
194
+ if hints:
195
+ for h in hints:
196
+ out.append(f"\n# {h.get('language') or 'snippet'}\n{h.get('snippet') or ''}")
197
+ else:
198
+ out.append(f"\n# generated (no schema-card hints for this archive)\n{_fallback_snippet(a)}")
199
+ if card.get("starterCell"):
200
+ out.append("\n# worked example (starter cell)\n" + card["starterCell"])
201
+ return "\n".join(out)
202
+
203
+
204
+ # NOTE: a `lookup_paper` tool (paper -> linked archives) was dropped before the
205
+ # first PyPI release. The backing endpoint, /api/papers/lookup, is arXiv-only:
206
+ # it resolves bare arXiv ids and free-text keywords, but not DOIs, "arXiv:"-
207
+ # prefixed ids, or titles of papers that are not on arXiv — while the tool
208
+ # description advertised all four. Re-add it here once the backend resolves
209
+ # DOIs/titles, so the tool description can be true.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: scigantic-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for the Scigantic catalog of public scientific data archives, with LLM-ready schema cards
5
+ Author: Scigantic
6
+ License: MIT-0
7
+ Project-URL: Homepage, https://scigantic.com
8
+ Project-URL: Documentation, https://scigantic.com/blog/scigantic-mcp-server
9
+ Keywords: mcp,model-context-protocol,datasets,open-data,schema-card,bioinformatics,earth-observation
10
+ Classifier: License :: OSI Approved :: MIT No Attribution License (MIT-0)
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: mcp<3,>=2
18
+ Requires-Dist: httpx>=0.27
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7; extra == "test"
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # scigantic-mcp
25
+
26
+ A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes the
27
+ **Scigantic catalog of public scientific data archives** to any MCP client.
28
+
29
+ It is built to drop into **[Kiro for Life Sciences](https://github.com/aws-samples/sample-kiro-power-life-sciences)**
30
+ alongside its domain database servers. Where each Kiro server wraps one domain's
31
+ APIs (genomics, proteomics, structural, …), Scigantic is the **cross-domain
32
+ launchpad**: 5,000+ curated public archives spanning every domain, each with an
33
+ LLM-ready **schema card** (file format, columns, sample rows/headers, inlined
34
+ READMEs/data dictionaries, and a copy-paste starter cell) so an agent can
35
+ understand a dataset's structure *before* downloading anything.
36
+
37
+ It is **discovery-only and zero-config** — every tool calls public, read-only
38
+ Scigantic endpoints, so there is no API key to set up.
39
+
40
+ Requires Python 3.10+ and MCP SDK 2.x.
41
+
42
+ > **Prefer the hosted server if your client speaks HTTP.** Scigantic also runs a
43
+ > remote MCP server at `https://api.scigantic.com/mcp` (no auth, nothing to
44
+ > install, and it carries two extra tools):
45
+ > `claude mcp add --transport http scigantic https://api.scigantic.com/mcp`.
46
+ > This package exists for clients that launch **stdio** servers, such as Kiro.
47
+
48
+ ## Tools
49
+
50
+ | Tool | What it does |
51
+ |------|--------------|
52
+ | `search_archives(query, category?, limit?)` | Natural-language search across the whole catalog. |
53
+ | `get_archive(id)` | Full metadata for one archive. |
54
+ | `get_schema_card(id)` | The compact schema card — the fastest way to learn a dataset's structure. |
55
+ | `get_data_access(id, language?)` | How to load the dataset in **your own** environment: storage location + copy-paste code snippets. |
56
+ | `list_archive_files(id, limit?)` | A sample of the files/objects in the archive. |
57
+
58
+ ## Prompts (guided workflows)
59
+
60
+ These surface as slash commands in Claude Code (`/mcp__scigantic__<name>`):
61
+
62
+ | Prompt | What it does |
63
+ |--------|--------------|
64
+ | `explore_dataset(topic)` | Search → inspect schema cards → recommend the best dataset → offer load code. |
65
+ | `start_analysis(archive_id, goal?)` | Pull schema card + data-access snippet for an archive and outline an analysis plan. |
66
+
67
+ ## Install & register in Kiro
68
+
69
+ Add an entry under `mcpServers` in `~/.kiro/settings/mcp.json`.
70
+
71
+ **Option A — `uvx` (zero-install, recommended):**
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "scigantic": {
77
+ "command": "uvx",
78
+ "args": ["scigantic-mcp"]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ **Option B — install into a venv (matches the Kiro servers' own mcp.json form):**
85
+
86
+ ```bash
87
+ python3 -m venv .venv && . .venv/bin/activate
88
+ pip install scigantic-mcp # or: pip install /path/to/scigantic-mcp
89
+ ```
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "scigantic": {
95
+ "command": "/path/to/.venv/bin/scigantic-mcp",
96
+ "env": {
97
+ "SCIGANTIC_API_URL": "https://api.scigantic.com"
98
+ }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Works the same in Claude Desktop / Claude Code (`claude mcp add scigantic -- uvx scigantic-mcp`)
105
+ or any MCP client that launches stdio servers.
106
+
107
+ ## Configuration
108
+
109
+ | Env var | Default | Purpose |
110
+ |---------|---------|---------|
111
+ | `SCIGANTIC_API_URL` | `https://api.scigantic.com` | API base (set to `https://staging-api.scigantic.com` for staging). |
112
+ | `SCIGANTIC_API_ORIGIN` | `https://scigantic.com` | Origin header → selects the public (default) catalog tenant. |
113
+
114
+ ## Develop & test
115
+
116
+ The tool/client layer has no `mcp` dependency, so those tests run on any Python
117
+ with `httpx` and need no network (mocked transport):
118
+
119
+ ```bash
120
+ python3 tests/test_tools.py # or: pytest
121
+ ```
122
+
123
+ `tests/test_server_import.py` covers the wiring layer — that the server module
124
+ imports, and that the registered tools and prompts are the expected set. It needs
125
+ the `mcp` SDK installed (Python ≥ 3.10) and is skipped otherwise:
126
+
127
+ ```bash
128
+ pip install -e '.[test]' && pytest
129
+ ```
130
+
131
+ Keep it that way: the tool tests skip `server.py` on purpose, so an SDK breaking
132
+ change is invisible to them. All 12 passed while the server could not import at
133
+ all under SDK 2.x, which is what `test_server_import.py` now guards against.
134
+
135
+ ## Roadmap
136
+
137
+ - **Richer discovery for agents** — structured tool outputs and MCP *resources*
138
+ (attach an archive + its schema card as durable context).
139
+ - Upstream inclusion as `life-sciences-scigantic` in `aws-samples/sample-kiro-power-life-sciences`
140
+ (see `docs/kiro-upstream-pr.md`).
141
+ - **Hosted compute is intentionally not exposed here.** Scigantic's notebooks are
142
+ interactive (a JupyterLab URL a human opens); handing an external agent that URL
143
+ is a dead end. The agent-to-agent path is `get_data_access` — the caller runs the
144
+ analysis in its own environment. Letting Scigantic *execute code for* an agent
145
+ (run against an ephemeral kernel with the dataset mounted, return outputs) is a
146
+ separate capability the platform would need to build first.
147
+
148
+ ## License
149
+
150
+ MIT-0.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ scigantic_mcp/__init__.py
5
+ scigantic_mcp/client.py
6
+ scigantic_mcp/server.py
7
+ scigantic_mcp/tools.py
8
+ scigantic_mcp.egg-info/PKG-INFO
9
+ scigantic_mcp.egg-info/SOURCES.txt
10
+ scigantic_mcp.egg-info/dependency_links.txt
11
+ scigantic_mcp.egg-info/entry_points.txt
12
+ scigantic_mcp.egg-info/requires.txt
13
+ scigantic_mcp.egg-info/top_level.txt
14
+ tests/test_server_import.py
15
+ tests/test_tools.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scigantic-mcp = scigantic_mcp.server:main
@@ -0,0 +1,6 @@
1
+ mcp<3,>=2
2
+ httpx>=0.27
3
+
4
+ [test]
5
+ pytest>=7
6
+ pytest-asyncio>=0.23
@@ -0,0 +1 @@
1
+ scigantic_mcp
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,67 @@
1
+ """Smoke test for the server wiring layer — needs the mcp SDK.
2
+
3
+ This exists because tests/test_tools.py deliberately imports only .tools/.client
4
+ so it can run offline on any Python. That left server.py with ZERO coverage, and
5
+ it is exactly where the SDK contract lives: when SDK 2.0 removed
6
+ `mcp.server.fastmcp`, every tool test still passed while the server crashed on
7
+ import. Anything that would break `uvx scigantic-mcp` at startup belongs here.
8
+
9
+ Skipped automatically when mcp is not installed (e.g. Python < 3.10).
10
+ """
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
18
+
19
+ pytest.importorskip("mcp", reason="mcp SDK not installed (needs Python >= 3.10)")
20
+
21
+ EXPECTED_TOOLS = {
22
+ "search_archives",
23
+ "get_archive",
24
+ "get_schema_card",
25
+ "list_archive_files",
26
+ "get_data_access",
27
+ }
28
+ EXPECTED_PROMPTS = {"explore_dataset", "start_analysis"}
29
+
30
+
31
+ def test_server_module_imports():
32
+ """The import that SDK 2.0 broke. Guards the console-script entry point."""
33
+ from scigantic_mcp import server
34
+
35
+ assert callable(server.main)
36
+
37
+
38
+ def test_registered_tools_match_expected():
39
+ import asyncio
40
+
41
+ from scigantic_mcp import server
42
+
43
+ names = {t.name for t in asyncio.run(_list(server.mcp.list_tools()))}
44
+ assert names == EXPECTED_TOOLS
45
+
46
+
47
+ def test_registered_prompts_match_expected():
48
+ import asyncio
49
+
50
+ from scigantic_mcp import server
51
+
52
+ names = {p.name for p in asyncio.run(_list(server.mcp.list_prompts()))}
53
+ assert names == EXPECTED_PROMPTS
54
+
55
+
56
+ def test_server_reports_package_version():
57
+ """MCPServer takes an explicit version; SDK 1.x's FastMCP reported the SDK's."""
58
+ from scigantic_mcp import __version__, server
59
+
60
+ assert server.mcp.version == __version__
61
+
62
+
63
+ async def _list(maybe_awaitable):
64
+ """list_tools()/list_prompts() are sync in some SDK builds, async in others."""
65
+ import inspect
66
+
67
+ return await maybe_awaitable if inspect.isawaitable(maybe_awaitable) else maybe_awaitable
@@ -0,0 +1,216 @@
1
+ """Offline tests for the Scigantic MCP tool layer.
2
+
3
+ Uses httpx.MockTransport so nothing hits the network. Each test wraps its async
4
+ body in asyncio.run(), so it runs under plain `pytest` without pytest-asyncio,
5
+ and the file is also directly executable: `python3 tests/test_tools.py`.
6
+
7
+ Note: these import scigantic_mcp.tools / .client only — NOT .server — so they
8
+ run on any Python with httpx, independent of the mcp SDK (which needs 3.10+).
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
19
+
20
+ from scigantic_mcp import tools # noqa: E402
21
+ from scigantic_mcp.client import NotFoundError, ScigateClient # noqa: E402
22
+
23
+
24
+ def make_client(handler) -> ScigateClient:
25
+ transport = httpx.MockTransport(handler)
26
+ http = httpx.AsyncClient(transport=transport, base_url="https://api.test")
27
+ return ScigateClient(base_url="https://api.test", http_client=http)
28
+
29
+
30
+ # --- fixtures: canned API responses keyed by (method, path) -----------------
31
+
32
+ ARCHIVE_WITH_CARD = {
33
+ "id": "a1",
34
+ "title": "Test Archive",
35
+ "category": "Genomics & Bioinformatics",
36
+ "cloudProvider": "huggingface",
37
+ "license": "CC-BY-4.0",
38
+ "summary": "A tiny test dataset",
39
+ "description": "Longer description of the test dataset.",
40
+ "websiteUrl": "https://example.org/a1",
41
+ "bucketName": "test-bucket",
42
+ "region": "us-east-1",
43
+ "bucketAccessType": "public",
44
+ "schemaCard": {
45
+ "format": "parquet",
46
+ "columns": [{"name": "gene", "type": "string"}],
47
+ "accessHints": [{"language": "python-datasets", "snippet": "from datasets import load_dataset\nds = load_dataset(\"test-bucket\")"}],
48
+ "starterCell": "import pandas as pd # worked example",
49
+ },
50
+ }
51
+
52
+ HF_NO_CARD = {
53
+ "id": "hfnocard",
54
+ "title": "HF No Card",
55
+ "cloudProvider": "huggingface",
56
+ "bucketName": "org/dataset",
57
+ "bucketAccessType": "public",
58
+ }
59
+
60
+
61
+ def default_handler(request: httpx.Request) -> httpx.Response:
62
+ path = request.url.path
63
+ method = request.method
64
+ if method == "GET" and path == "/api/archives":
65
+ return httpx.Response(200, json={"success": True, "count": 1, "data": [ARCHIVE_WITH_CARD]})
66
+ if method == "GET" and path == "/api/archives/a1":
67
+ return httpx.Response(200, json={"success": True, "data": ARCHIVE_WITH_CARD})
68
+ if method == "GET" and path == "/api/archives/nocard":
69
+ return httpx.Response(200, json={"success": True, "data": {"id": "nocard", "title": "No Card"}})
70
+ if method == "GET" and path == "/api/archives/hfnocard":
71
+ return httpx.Response(200, json={"success": True, "data": HF_NO_CARD})
72
+ if method == "GET" and path == "/api/archives/missing":
73
+ return httpx.Response(404, json={"error": {"message": "not found"}})
74
+ if method == "GET" and path == "/api/archives/a1/files":
75
+ return httpx.Response(200, json={"success": True, "data": {"files": [
76
+ {"name": "data.parquet", "size": 1024}, {"name": "README.md", "size": 50}]}})
77
+ return httpx.Response(404, json={"error": {"message": "unmapped"}})
78
+
79
+
80
+ # --- tests -------------------------------------------------------------------
81
+
82
+ def test_search_archives_formats_results():
83
+ async def body():
84
+ c = make_client(default_handler)
85
+ out = await tools.search_archives(c, query="genomics", limit=5)
86
+ assert "Test Archive" in out
87
+ assert "id: a1" in out
88
+ assert "https://example.org/a1" in out # falls back to websiteUrl
89
+ await c.aclose()
90
+ asyncio.run(body())
91
+
92
+
93
+ def test_search_requires_query():
94
+ async def body():
95
+ c = make_client(default_handler)
96
+ assert "query is required" in await tools.search_archives(c, query=" ")
97
+ await c.aclose()
98
+ asyncio.run(body())
99
+
100
+
101
+ def test_get_archive_notes_schema_card():
102
+ async def body():
103
+ c = make_client(default_handler)
104
+ out = await tools.get_archive(c, id="a1")
105
+ assert "CC-BY-4.0" in out
106
+ assert "schema card is available" in out
107
+ await c.aclose()
108
+ asyncio.run(body())
109
+
110
+
111
+ def test_get_archive_404():
112
+ async def body():
113
+ c = make_client(default_handler)
114
+ assert "not found" in (await tools.get_archive(c, id="missing")).lower()
115
+ await c.aclose()
116
+ asyncio.run(body())
117
+
118
+
119
+ def test_get_schema_card_present_and_absent():
120
+ async def body():
121
+ c = make_client(default_handler)
122
+ present = await tools.get_schema_card(c, id="a1")
123
+ assert "parquet" in present
124
+ assert json.loads(present[present.index("{"):]) # the JSON body parses
125
+ absent = await tools.get_schema_card(c, id="nocard")
126
+ assert "No schema card" in absent
127
+ await c.aclose()
128
+ asyncio.run(body())
129
+
130
+
131
+ def test_list_archive_files():
132
+ async def body():
133
+ c = make_client(default_handler)
134
+ out = await tools.list_archive_files(c, id="a1")
135
+ assert "data.parquet (1024 bytes)" in out
136
+ await c.aclose()
137
+ asyncio.run(body())
138
+
139
+
140
+ def test_get_data_access_uses_schema_card_hints():
141
+ async def body():
142
+ c = make_client(default_handler)
143
+ out = await tools.get_data_access(c, id="a1")
144
+ assert "test-bucket" in out
145
+ assert "load_dataset" in out # the card's accessHint snippet
146
+ assert "worked example" in out # the starterCell
147
+ assert "region: us-east-1" in out
148
+ await c.aclose()
149
+ asyncio.run(body())
150
+
151
+
152
+ def test_get_data_access_filters_by_language():
153
+ async def body():
154
+ c = make_client(default_handler)
155
+ out = await tools.get_data_access(c, id="a1", language="datasets")
156
+ assert "load_dataset" in out
157
+ await c.aclose()
158
+ asyncio.run(body())
159
+
160
+
161
+ def test_get_data_access_fallback_when_no_card():
162
+ async def body():
163
+ c = make_client(default_handler)
164
+ out = await tools.get_data_access(c, id="hfnocard")
165
+ assert "generated (no schema-card hints" in out
166
+ assert 'load_dataset("org/dataset")' in out # HF fallback
167
+ await c.aclose()
168
+ asyncio.run(body())
169
+
170
+
171
+ def test_client_retries_on_503_then_succeeds():
172
+ calls = {"n": 0}
173
+
174
+ def flaky(request: httpx.Request) -> httpx.Response:
175
+ if request.url.path == "/api/archives":
176
+ calls["n"] += 1
177
+ if calls["n"] == 1:
178
+ return httpx.Response(503, json={"error": {"message": "try later"}})
179
+ return httpx.Response(200, json={"success": True, "data": [ARCHIVE_WITH_CARD]})
180
+ return httpx.Response(404)
181
+
182
+ async def body():
183
+ c = make_client(flaky)
184
+ c.max_retries = 2
185
+ out = await tools.search_archives(c, query="genomics")
186
+ assert "Test Archive" in out
187
+ assert calls["n"] == 2 # one retry happened
188
+ await c.aclose()
189
+ asyncio.run(body())
190
+
191
+
192
+ def test_client_raises_notfound():
193
+ async def body():
194
+ c = make_client(default_handler)
195
+ raised = False
196
+ try:
197
+ await c.get_json("/api/archives/missing")
198
+ except NotFoundError:
199
+ raised = True
200
+ assert raised
201
+ await c.aclose()
202
+ asyncio.run(body())
203
+
204
+
205
+ if __name__ == "__main__":
206
+ failures = 0
207
+ for name, fn in sorted(globals().items()):
208
+ if name.startswith("test_") and callable(fn):
209
+ try:
210
+ fn()
211
+ print(f"PASS {name}")
212
+ except Exception as exc: # noqa: BLE001
213
+ failures += 1
214
+ print(f"FAIL {name}: {exc}")
215
+ print(f"\n{'OK' if failures == 0 else 'FAILURES: ' + str(failures)}")
216
+ sys.exit(1 if failures else 0)