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.
- scigantic_mcp-0.1.0/LICENSE +17 -0
- scigantic_mcp-0.1.0/PKG-INFO +150 -0
- scigantic_mcp-0.1.0/README.md +127 -0
- scigantic_mcp-0.1.0/pyproject.toml +42 -0
- scigantic_mcp-0.1.0/scigantic_mcp/__init__.py +9 -0
- scigantic_mcp-0.1.0/scigantic_mcp/client.py +124 -0
- scigantic_mcp-0.1.0/scigantic_mcp/server.py +150 -0
- scigantic_mcp-0.1.0/scigantic_mcp/tools.py +209 -0
- scigantic_mcp-0.1.0/scigantic_mcp.egg-info/PKG-INFO +150 -0
- scigantic_mcp-0.1.0/scigantic_mcp.egg-info/SOURCES.txt +15 -0
- scigantic_mcp-0.1.0/scigantic_mcp.egg-info/dependency_links.txt +1 -0
- scigantic_mcp-0.1.0/scigantic_mcp.egg-info/entry_points.txt +2 -0
- scigantic_mcp-0.1.0/scigantic_mcp.egg-info/requires.txt +6 -0
- scigantic_mcp-0.1.0/scigantic_mcp.egg-info/top_level.txt +1 -0
- scigantic_mcp-0.1.0/setup.cfg +4 -0
- scigantic_mcp-0.1.0/tests/test_server_import.py +67 -0
- scigantic_mcp-0.1.0/tests/test_tools.py +216 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scigantic_mcp
|
|
@@ -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)
|