smartapi-mcp 0.2.0__tar.gz → 0.3.1__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.
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/PKG-INFO +59 -4
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/README.md +55 -2
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/pyproject.toml +8 -2
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/smartapi_mcp/__init__.py +13 -2
- smartapi_mcp-0.3.1/smartapi_mcp/biothings.py +381 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/smartapi_mcp/cli.py +96 -15
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/smartapi_mcp/config.py +26 -1
- smartapi_mcp-0.3.1/smartapi_mcp/server.py +256 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/smartapi_mcp/smartapi.py +53 -14
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/smartapi_mcp.egg-info/SOURCES.txt +1 -0
- smartapi_mcp-0.2.0/smartapi_mcp/server.py +0 -121
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/LICENSE +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/MANIFEST.in +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/requirements-dev.txt +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/requirements.txt +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/setup.cfg +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/setup.py +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/smartapi_mcp/__main__.py +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.1}/smartapi_mcp/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: smartapi-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Create MCP servers for one or multiple APIs registered in SmartAPI registry
|
|
5
5
|
Author-email: BioThings Team <help@biothings.io>
|
|
6
6
|
Maintainer-email: BioThings Team <help@biothings.io>
|
|
@@ -18,13 +18,15 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
23
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
23
24
|
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
24
25
|
Requires-Python: >=3.10
|
|
25
26
|
Description-Content-Type: text/markdown
|
|
26
27
|
License-File: LICENSE
|
|
27
|
-
Requires-Dist: awslabs_openapi_mcp_server
|
|
28
|
+
Requires-Dist: awslabs_openapi_mcp_server<1,>=0.2.12
|
|
29
|
+
Requires-Dist: fastmcp<3,>=2.14
|
|
28
30
|
Provides-Extra: dev
|
|
29
31
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
30
32
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
@@ -60,7 +62,9 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
|
|
|
60
62
|
|
|
61
63
|
- Python 3.10 or higher
|
|
62
64
|
- Network access to SmartAPI registry (https://smart-api.info)
|
|
63
|
-
- Dependencies: `awslabs_openapi_mcp_server>=0.2.12`
|
|
65
|
+
- Dependencies: `awslabs_openapi_mcp_server>=0.2.12,<1` and `fastmcp>=2.14,<3`
|
|
66
|
+
(awslabs 1.x / fastmcp 3.x are not yet supported — see the dependency notes in
|
|
67
|
+
`pyproject.toml`)
|
|
64
68
|
|
|
65
69
|
## Features
|
|
66
70
|
|
|
@@ -70,9 +74,10 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
|
|
|
70
74
|
- 📖 **OpenAPI Validation**: Automatic OpenAPI specification parsing and validation
|
|
71
75
|
- 🛠️ **CLI Interface**: Easy-to-use command-line interface with multiple configuration options
|
|
72
76
|
- 🧬 **Bioinformatics Focus**: Pre-configured API sets for bioinformatics and life sciences
|
|
77
|
+
- 🧩 **Scales to large API sets**: Large BioThings sets (e.g. `biothings_all`, 200+ operations) automatically collapse to ~5 generic tools, avoiding client context overflow without relying on client-side tool deferral
|
|
73
78
|
- 🎯 **Flexible Configuration**: Support for environment variables, arguments, and configuration files
|
|
74
79
|
- 🚀 **Multiple Transport Modes**: Support for both stdio and HTTP transport protocols
|
|
75
|
-
- 🧪 **Comprehensive Testing**: Full test suite with
|
|
80
|
+
- 🧪 **Comprehensive Testing**: Full test suite with high code coverage
|
|
76
81
|
|
|
77
82
|
## Installation
|
|
78
83
|
|
|
@@ -197,6 +202,51 @@ For other MCP clients that support external MCP servers, you can typically confi
|
|
|
197
202
|
}
|
|
198
203
|
```
|
|
199
204
|
|
|
205
|
+
#### Large API Sets (`biothings_all`) — the BioThings facade
|
|
206
|
+
|
|
207
|
+
All BioThings APIs share the same handful of operations (`/query`,
|
|
208
|
+
`/{type}/{id}`, batch lookups, `/metadata/fields`). Emitting one MCP tool per
|
|
209
|
+
(API × operation) for a large set like `biothings_all` would create 200+
|
|
210
|
+
near-duplicate tools and overflow the client's context window.
|
|
211
|
+
|
|
212
|
+
To avoid this, for large BioThings sets the server exposes a small **fixed set
|
|
213
|
+
of ~5 generic tools** where the target API is a *parameter*:
|
|
214
|
+
|
|
215
|
+
- `list_biothings_apis` — discover/search the available APIs (returns data)
|
|
216
|
+
- `biothings_query` — search any API (`/query`)
|
|
217
|
+
- `biothings_get` / `biothings_getbatch` — annotation lookup by id(s)
|
|
218
|
+
- `biothings_fields` — list queryable fields for an API
|
|
219
|
+
|
|
220
|
+
Because the tool list is small and static, this works on **every** MCP client
|
|
221
|
+
(no dependency on dynamic `tools/list_changed` updates).
|
|
222
|
+
|
|
223
|
+
For a **mixed** set (BioThings + other API types), the server is *hybrid*: the
|
|
224
|
+
BioThings APIs are served through the facade, while any non-BioThings APIs are
|
|
225
|
+
added as faithful per-API tools in the same server — so no APIs are lost.
|
|
226
|
+
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"mcpServers": {
|
|
230
|
+
"biothings-all": {
|
|
231
|
+
"command": "uvx",
|
|
232
|
+
"args": ["smartapi-mcp", "--api_set", "biothings_all"]
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Control the strategy with `--facade {auto,on,off}` (default `auto`, which
|
|
239
|
+
engages the facade once the BioThings APIs in the set reach
|
|
240
|
+
`--facade-threshold`, default 10) or the `SMARTAPI_FACADE` environment variable.
|
|
241
|
+
Use `--facade off` to force faithful per-API tools regardless of set size.
|
|
242
|
+
|
|
243
|
+
A few BioThings APIs expose extra, non-standard endpoints (e.g. SemmedDB's
|
|
244
|
+
`/query/ngd`) that the generic facade tools can't reach. These are rare, so by
|
|
245
|
+
default they're ignored. Pass `--facade-strict` (or `FACADE_STRICT=1`) to
|
|
246
|
+
inspect each BioThings spec at startup and serve any API that has extra
|
|
247
|
+
endpoints with faithful per-API tools instead — at the cost of slower startup
|
|
248
|
+
(it downloads the specs upfront).
|
|
249
|
+
|
|
200
250
|
#### Development/Testing Setup
|
|
201
251
|
|
|
202
252
|
```json
|
|
@@ -482,6 +532,11 @@ export SMARTAPI_Q="tags.name=biothings"
|
|
|
482
532
|
export SMARTAPI_API_SET="biothings_core"
|
|
483
533
|
export SMARTAPI_EXCLUDE_IDS="exclude_id1,exclude_id2"
|
|
484
534
|
|
|
535
|
+
# BioThings facade strategy (see "Large API Sets" above)
|
|
536
|
+
export SMARTAPI_FACADE="auto" # auto | on | off
|
|
537
|
+
export FACADE_THRESHOLD="10" # min BioThings APIs before 'auto' uses the facade
|
|
538
|
+
export FACADE_STRICT="false" # inspect specs; serve APIs with extra endpoints per-API
|
|
539
|
+
|
|
485
540
|
# Server configuration
|
|
486
541
|
export SERVER_NAME="My SmartAPI MCP Server"
|
|
487
542
|
export TRANSPORT="http"
|
|
@@ -17,7 +17,9 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
|
|
|
17
17
|
|
|
18
18
|
- Python 3.10 or higher
|
|
19
19
|
- Network access to SmartAPI registry (https://smart-api.info)
|
|
20
|
-
- Dependencies: `awslabs_openapi_mcp_server>=0.2.12`
|
|
20
|
+
- Dependencies: `awslabs_openapi_mcp_server>=0.2.12,<1` and `fastmcp>=2.14,<3`
|
|
21
|
+
(awslabs 1.x / fastmcp 3.x are not yet supported — see the dependency notes in
|
|
22
|
+
`pyproject.toml`)
|
|
21
23
|
|
|
22
24
|
## Features
|
|
23
25
|
|
|
@@ -27,9 +29,10 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
|
|
|
27
29
|
- 📖 **OpenAPI Validation**: Automatic OpenAPI specification parsing and validation
|
|
28
30
|
- 🛠️ **CLI Interface**: Easy-to-use command-line interface with multiple configuration options
|
|
29
31
|
- 🧬 **Bioinformatics Focus**: Pre-configured API sets for bioinformatics and life sciences
|
|
32
|
+
- 🧩 **Scales to large API sets**: Large BioThings sets (e.g. `biothings_all`, 200+ operations) automatically collapse to ~5 generic tools, avoiding client context overflow without relying on client-side tool deferral
|
|
30
33
|
- 🎯 **Flexible Configuration**: Support for environment variables, arguments, and configuration files
|
|
31
34
|
- 🚀 **Multiple Transport Modes**: Support for both stdio and HTTP transport protocols
|
|
32
|
-
- 🧪 **Comprehensive Testing**: Full test suite with
|
|
35
|
+
- 🧪 **Comprehensive Testing**: Full test suite with high code coverage
|
|
33
36
|
|
|
34
37
|
## Installation
|
|
35
38
|
|
|
@@ -154,6 +157,51 @@ For other MCP clients that support external MCP servers, you can typically confi
|
|
|
154
157
|
}
|
|
155
158
|
```
|
|
156
159
|
|
|
160
|
+
#### Large API Sets (`biothings_all`) — the BioThings facade
|
|
161
|
+
|
|
162
|
+
All BioThings APIs share the same handful of operations (`/query`,
|
|
163
|
+
`/{type}/{id}`, batch lookups, `/metadata/fields`). Emitting one MCP tool per
|
|
164
|
+
(API × operation) for a large set like `biothings_all` would create 200+
|
|
165
|
+
near-duplicate tools and overflow the client's context window.
|
|
166
|
+
|
|
167
|
+
To avoid this, for large BioThings sets the server exposes a small **fixed set
|
|
168
|
+
of ~5 generic tools** where the target API is a *parameter*:
|
|
169
|
+
|
|
170
|
+
- `list_biothings_apis` — discover/search the available APIs (returns data)
|
|
171
|
+
- `biothings_query` — search any API (`/query`)
|
|
172
|
+
- `biothings_get` / `biothings_getbatch` — annotation lookup by id(s)
|
|
173
|
+
- `biothings_fields` — list queryable fields for an API
|
|
174
|
+
|
|
175
|
+
Because the tool list is small and static, this works on **every** MCP client
|
|
176
|
+
(no dependency on dynamic `tools/list_changed` updates).
|
|
177
|
+
|
|
178
|
+
For a **mixed** set (BioThings + other API types), the server is *hybrid*: the
|
|
179
|
+
BioThings APIs are served through the facade, while any non-BioThings APIs are
|
|
180
|
+
added as faithful per-API tools in the same server — so no APIs are lost.
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"mcpServers": {
|
|
185
|
+
"biothings-all": {
|
|
186
|
+
"command": "uvx",
|
|
187
|
+
"args": ["smartapi-mcp", "--api_set", "biothings_all"]
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Control the strategy with `--facade {auto,on,off}` (default `auto`, which
|
|
194
|
+
engages the facade once the BioThings APIs in the set reach
|
|
195
|
+
`--facade-threshold`, default 10) or the `SMARTAPI_FACADE` environment variable.
|
|
196
|
+
Use `--facade off` to force faithful per-API tools regardless of set size.
|
|
197
|
+
|
|
198
|
+
A few BioThings APIs expose extra, non-standard endpoints (e.g. SemmedDB's
|
|
199
|
+
`/query/ngd`) that the generic facade tools can't reach. These are rare, so by
|
|
200
|
+
default they're ignored. Pass `--facade-strict` (or `FACADE_STRICT=1`) to
|
|
201
|
+
inspect each BioThings spec at startup and serve any API that has extra
|
|
202
|
+
endpoints with faithful per-API tools instead — at the cost of slower startup
|
|
203
|
+
(it downloads the specs upfront).
|
|
204
|
+
|
|
157
205
|
#### Development/Testing Setup
|
|
158
206
|
|
|
159
207
|
```json
|
|
@@ -439,6 +487,11 @@ export SMARTAPI_Q="tags.name=biothings"
|
|
|
439
487
|
export SMARTAPI_API_SET="biothings_core"
|
|
440
488
|
export SMARTAPI_EXCLUDE_IDS="exclude_id1,exclude_id2"
|
|
441
489
|
|
|
490
|
+
# BioThings facade strategy (see "Large API Sets" above)
|
|
491
|
+
export SMARTAPI_FACADE="auto" # auto | on | off
|
|
492
|
+
export FACADE_THRESHOLD="10" # min BioThings APIs before 'auto' uses the facade
|
|
493
|
+
export FACADE_STRICT="false" # inspect specs; serve APIs with extra endpoints per-API
|
|
494
|
+
|
|
442
495
|
# Server configuration
|
|
443
496
|
export SERVER_NAME="My SmartAPI MCP Server"
|
|
444
497
|
export TRANSPORT="http"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "smartapi-mcp"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Create MCP servers for one or multiple APIs registered in SmartAPI registry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -24,6 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Programming Language :: Python :: 3.11",
|
|
25
25
|
"Programming Language :: Python :: 3.12",
|
|
26
26
|
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
27
28
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
29
|
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
29
30
|
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
@@ -31,7 +32,12 @@ classifiers = [
|
|
|
31
32
|
keywords = ["mcp", "smartapi", "api", "server", "bioinformatics"]
|
|
32
33
|
requires-python = ">=3.10"
|
|
33
34
|
dependencies = [
|
|
34
|
-
|
|
35
|
+
# awslabs 1.x requires fastmcp 3.x, which removed FastMCP.get_tools() and
|
|
36
|
+
# rejects *args tool functions -- incompatible with this package for now.
|
|
37
|
+
# fastmcp is also pinned <3 directly because awslabs 0.2.x allows fastmcp>=2.14
|
|
38
|
+
# with no upper bound and would otherwise resolve to the breaking 3.x.
|
|
39
|
+
"awslabs_openapi_mcp_server>=0.2.12,<1",
|
|
40
|
+
"fastmcp>=2.14,<3",
|
|
35
41
|
]
|
|
36
42
|
|
|
37
43
|
[project.optional-dependencies]
|
|
@@ -4,28 +4,39 @@ SmartAPI MCP Server Package
|
|
|
4
4
|
Create MCP servers for one or multiple APIs registered in SmartAPI registry.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.3.1"
|
|
8
8
|
__author__ = "BioThings Team"
|
|
9
9
|
__email__ = "help@biothings.io"
|
|
10
10
|
|
|
11
11
|
# Optional imports for when dependencies are available
|
|
12
12
|
try:
|
|
13
|
-
from .
|
|
13
|
+
from .biothings import build_biothings_facade, build_registry
|
|
14
|
+
from .server import (
|
|
15
|
+
build_server_for_set,
|
|
16
|
+
get_mcp_server,
|
|
17
|
+
get_merged_mcp_server,
|
|
18
|
+
merge_mcp_servers,
|
|
19
|
+
)
|
|
14
20
|
from .smartapi import (
|
|
15
21
|
PREDEFINED_API_SETS,
|
|
16
22
|
get_base_server_url,
|
|
17
23
|
get_predefined_api_set,
|
|
18
24
|
get_smartapi_ids,
|
|
25
|
+
get_smartapi_registry,
|
|
19
26
|
load_api_spec,
|
|
20
27
|
)
|
|
21
28
|
|
|
22
29
|
__all__ = [
|
|
23
30
|
"PREDEFINED_API_SETS",
|
|
31
|
+
"build_biothings_facade",
|
|
32
|
+
"build_registry",
|
|
33
|
+
"build_server_for_set",
|
|
24
34
|
"get_base_server_url",
|
|
25
35
|
"get_mcp_server",
|
|
26
36
|
"get_merged_mcp_server",
|
|
27
37
|
"get_predefined_api_set",
|
|
28
38
|
"get_smartapi_ids",
|
|
39
|
+
"get_smartapi_registry",
|
|
29
40
|
"load_api_spec",
|
|
30
41
|
"merge_mcp_servers",
|
|
31
42
|
]
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BioThings generic-facade MCP server.
|
|
3
|
+
|
|
4
|
+
BioThings APIs all expose the same handful of operations (``/query``,
|
|
5
|
+
``/{type}/{id}``, batch ``POST /{type}``, ``/metadata/fields``). Rather than
|
|
6
|
+
emitting one MCP tool per (API x operation) -- which explodes to 200+ near
|
|
7
|
+
duplicate tools for ``biothings_all`` and overflows client context -- this
|
|
8
|
+
module exposes a small, fixed set of *generic* tools where the target API is a
|
|
9
|
+
parameter. The tool count stays constant (~5) no matter how many APIs are in
|
|
10
|
+
the set, so the server works on every MCP client without depending on runtime
|
|
11
|
+
``tools/list_changed`` notifications.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from awslabs.openapi_mcp_server import logger
|
|
20
|
+
from fastmcp import FastMCP
|
|
21
|
+
from fastmcp.tools import Tool
|
|
22
|
+
|
|
23
|
+
from .smartapi import (
|
|
24
|
+
HTTP_TIMEOUT,
|
|
25
|
+
get_base_server_url,
|
|
26
|
+
get_smartapi_registry,
|
|
27
|
+
load_api_spec,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Matches a BioThings annotation path like ``/gene/{geneid}`` and captures the
|
|
31
|
+
# entity (biothing) type segment.
|
|
32
|
+
_BIOTHING_PATH_RE = re.compile(r"^/(?P<type>[^/{}]+)/\{[^/]+\}$")
|
|
33
|
+
|
|
34
|
+
# Truncate API descriptions in discovery output to keep the payload small.
|
|
35
|
+
_MAX_DESC_LEN = 200
|
|
36
|
+
|
|
37
|
+
# HTTP methods recognized when enumerating spec operations.
|
|
38
|
+
_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class BioThingsAPIEntry:
|
|
43
|
+
"""A single BioThings API in the facade registry."""
|
|
44
|
+
|
|
45
|
+
name: str # short slug used as the ``api`` argument value, e.g. "mygene"
|
|
46
|
+
smartapi_id: str
|
|
47
|
+
title: str = ""
|
|
48
|
+
description: str = ""
|
|
49
|
+
tags: list[str] = field(default_factory=list)
|
|
50
|
+
# Resolved lazily on first use (one spec download per API):
|
|
51
|
+
base_url: str | None = None
|
|
52
|
+
biothing_type: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _slugify_title(title: str) -> str:
|
|
56
|
+
"""Turn an API title into a short slug, e.g. 'MyGene.info API' -> 'mygene'."""
|
|
57
|
+
slug = title.lower()
|
|
58
|
+
slug = re.sub(r"\.info\b", "", slug)
|
|
59
|
+
slug = re.sub(r"\bapi\b", "", slug)
|
|
60
|
+
slug = re.sub(r"[^a-z0-9]+", "_", slug).strip("_")
|
|
61
|
+
return slug or "api"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_registry_from_entries(entries: list[dict]) -> dict[str, BioThingsAPIEntry]:
|
|
65
|
+
"""Build a slug -> entry registry from raw SmartAPI registry records.
|
|
66
|
+
|
|
67
|
+
Slugs are derived from API titles and de-duplicated with numeric suffixes.
|
|
68
|
+
"""
|
|
69
|
+
registry: dict[str, BioThingsAPIEntry] = {}
|
|
70
|
+
for record in entries:
|
|
71
|
+
smartapi_id = record.get("_id", "")
|
|
72
|
+
if not smartapi_id:
|
|
73
|
+
continue
|
|
74
|
+
title = record.get("title", "") or smartapi_id
|
|
75
|
+
base_slug = _slugify_title(title)
|
|
76
|
+
slug = base_slug
|
|
77
|
+
suffix = 2
|
|
78
|
+
while slug in registry:
|
|
79
|
+
slug = f"{base_slug}_{suffix}"
|
|
80
|
+
suffix += 1
|
|
81
|
+
registry[slug] = BioThingsAPIEntry(
|
|
82
|
+
name=slug,
|
|
83
|
+
smartapi_id=smartapi_id,
|
|
84
|
+
title=title,
|
|
85
|
+
description=record.get("description", "") or "",
|
|
86
|
+
tags=list(record.get("tags", []) or []),
|
|
87
|
+
)
|
|
88
|
+
return registry
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def build_registry(
|
|
92
|
+
smartapi_ids: list[str] | None = None, *, q: str | None = None
|
|
93
|
+
) -> dict[str, BioThingsAPIEntry]:
|
|
94
|
+
"""Build the facade registry from a set of IDs (or a registry query)."""
|
|
95
|
+
entries = await get_smartapi_registry(q=q, ids=smartapi_ids)
|
|
96
|
+
return build_registry_from_entries(entries)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_biothings_registry(registry: dict[str, BioThingsAPIEntry]) -> bool:
|
|
100
|
+
"""True only if every API in the registry is tagged ``biothings``."""
|
|
101
|
+
if not registry:
|
|
102
|
+
return False
|
|
103
|
+
return all("biothings" in entry.tags for entry in registry.values())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _resolve_endpoints(entry: BioThingsAPIEntry) -> None:
|
|
107
|
+
"""Lazily resolve and cache ``base_url`` and ``biothing_type`` from the spec."""
|
|
108
|
+
if entry.base_url is not None and entry.biothing_type is not None:
|
|
109
|
+
return
|
|
110
|
+
spec = load_api_spec(entry.smartapi_id)
|
|
111
|
+
if entry.base_url is None:
|
|
112
|
+
entry.base_url = get_base_server_url(spec).rstrip("/")
|
|
113
|
+
if entry.biothing_type is None:
|
|
114
|
+
for path in spec.get("paths", {}):
|
|
115
|
+
match = _BIOTHING_PATH_RE.match(path)
|
|
116
|
+
if match:
|
|
117
|
+
entry.biothing_type = match.group("type")
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _normalize_path(path: str) -> str:
|
|
122
|
+
"""Replace any ``{param}`` segment with ``{}`` so paths compare structurally."""
|
|
123
|
+
return re.sub(r"\{[^/}]+\}", "{}", path)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _spec_operations(spec: dict) -> set[tuple[str, str]]:
|
|
127
|
+
"""Return the set of ``(METHOD, normalized_path)`` operations in a spec."""
|
|
128
|
+
ops: set[tuple[str, str]] = set()
|
|
129
|
+
for path, item in spec.get("paths", {}).items():
|
|
130
|
+
if not isinstance(item, dict):
|
|
131
|
+
continue
|
|
132
|
+
norm = _normalize_path(path)
|
|
133
|
+
for method in item:
|
|
134
|
+
if method.lower() in _HTTP_METHODS:
|
|
135
|
+
ops.add((method.upper(), norm))
|
|
136
|
+
return ops
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _standard_biothings_ops(biothing_type: str) -> set[tuple[str, str]]:
|
|
140
|
+
"""The operations a stock BioThings API exposes for a given entity type."""
|
|
141
|
+
return {
|
|
142
|
+
("GET", "/query"),
|
|
143
|
+
("POST", "/query"),
|
|
144
|
+
("GET", f"/{biothing_type}/{{}}"),
|
|
145
|
+
("POST", f"/{biothing_type}"),
|
|
146
|
+
("GET", "/metadata"),
|
|
147
|
+
("GET", "/metadata/fields"),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def analyze_biothings_spec(
|
|
152
|
+
spec: dict,
|
|
153
|
+
) -> tuple[str | None, str | None, list[tuple[str, str]]]:
|
|
154
|
+
"""Classify a BioThings spec for facade eligibility.
|
|
155
|
+
|
|
156
|
+
Returns ``(base_url, biothing_type, extra_ops)`` where ``extra_ops`` lists
|
|
157
|
+
operations that fall outside the standard BioThings interface. An empty
|
|
158
|
+
``extra_ops`` (with a detected ``biothing_type``) means the API is fully
|
|
159
|
+
covered by the generic facade tools; otherwise it should be served with
|
|
160
|
+
faithful per-API tools so the extra endpoints aren't hidden.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
base_url = get_base_server_url(spec).rstrip("/")
|
|
164
|
+
except (KeyError, ValueError, TypeError):
|
|
165
|
+
base_url = None
|
|
166
|
+
|
|
167
|
+
biothing_type = None
|
|
168
|
+
for path in spec.get("paths", {}):
|
|
169
|
+
match = _BIOTHING_PATH_RE.match(path)
|
|
170
|
+
if match:
|
|
171
|
+
biothing_type = match.group("type")
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
ops = _spec_operations(spec)
|
|
175
|
+
if biothing_type is None:
|
|
176
|
+
extra = sorted(ops)
|
|
177
|
+
else:
|
|
178
|
+
extra = sorted(ops - _standard_biothings_ops(biothing_type))
|
|
179
|
+
return base_url, biothing_type, extra
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def partition_biothings(
|
|
183
|
+
entries: dict[str, BioThingsAPIEntry],
|
|
184
|
+
) -> tuple[dict[str, BioThingsAPIEntry], list[str]]:
|
|
185
|
+
"""Split BioThings entries into facade-eligible vs. needs-per-API.
|
|
186
|
+
|
|
187
|
+
Loads each spec (in parallel) and classifies it with
|
|
188
|
+
:func:`analyze_biothings_spec`. Facade-eligible entries get their
|
|
189
|
+
``base_url``/``biothing_type`` cached and are returned in the first dict;
|
|
190
|
+
APIs with extra endpoints, no detectable entity type, or an unloadable spec
|
|
191
|
+
are returned as a list of SmartAPI IDs to be served with per-API tools.
|
|
192
|
+
"""
|
|
193
|
+
items = list(entries.items())
|
|
194
|
+
specs = await asyncio.gather(
|
|
195
|
+
*[asyncio.to_thread(load_api_spec, entry.smartapi_id) for _, entry in items],
|
|
196
|
+
return_exceptions=True,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
facade_entries: dict[str, BioThingsAPIEntry] = {}
|
|
200
|
+
extra_ids: list[str] = []
|
|
201
|
+
for (name, entry), spec in zip(items, specs, strict=True):
|
|
202
|
+
if isinstance(spec, Exception):
|
|
203
|
+
logger.warning(
|
|
204
|
+
f"Could not load spec for BioThings API '{name}' "
|
|
205
|
+
f"({entry.smartapi_id}): {spec}; serving with per-API tools."
|
|
206
|
+
)
|
|
207
|
+
extra_ids.append(entry.smartapi_id)
|
|
208
|
+
continue
|
|
209
|
+
base_url, biothing_type, extra_ops = analyze_biothings_spec(spec)
|
|
210
|
+
if biothing_type is None or extra_ops:
|
|
211
|
+
reason = (
|
|
212
|
+
f"non-standard endpoint(s) {extra_ops}"
|
|
213
|
+
if extra_ops
|
|
214
|
+
else "no detectable entity type"
|
|
215
|
+
)
|
|
216
|
+
logger.info(
|
|
217
|
+
f"BioThings API '{name}' has {reason}; "
|
|
218
|
+
"serving it with faithful per-API tools."
|
|
219
|
+
)
|
|
220
|
+
extra_ids.append(entry.smartapi_id)
|
|
221
|
+
else:
|
|
222
|
+
entry.base_url = base_url
|
|
223
|
+
entry.biothing_type = biothing_type
|
|
224
|
+
facade_entries[name] = entry
|
|
225
|
+
return facade_entries, extra_ids
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _score_entry(entry: BioThingsAPIEntry, tokens: list[str]) -> int:
|
|
229
|
+
"""Lexical relevance: count token hits across title/description/tags."""
|
|
230
|
+
haystack = " ".join(
|
|
231
|
+
[entry.name, entry.title, entry.description, " ".join(entry.tags)]
|
|
232
|
+
).lower()
|
|
233
|
+
return sum(haystack.count(token) for token in tokens)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def rank_apis(
|
|
237
|
+
registry: dict[str, BioThingsAPIEntry],
|
|
238
|
+
keyword: str | None = None,
|
|
239
|
+
limit: int = 10,
|
|
240
|
+
) -> list[dict]:
|
|
241
|
+
"""Rank APIs by lexical relevance to ``keyword`` (returns plain data).
|
|
242
|
+
|
|
243
|
+
With no keyword, returns the full catalog sorted by name. Descriptions are
|
|
244
|
+
truncated to keep the payload small.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def _as_dict(entry: BioThingsAPIEntry) -> dict:
|
|
248
|
+
desc = entry.description
|
|
249
|
+
if len(desc) > _MAX_DESC_LEN:
|
|
250
|
+
desc = desc[:_MAX_DESC_LEN].rstrip() + "..."
|
|
251
|
+
return {
|
|
252
|
+
"name": entry.name,
|
|
253
|
+
"title": entry.title,
|
|
254
|
+
"description": desc,
|
|
255
|
+
"tags": entry.tags,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if not keyword or not keyword.strip():
|
|
259
|
+
return [_as_dict(registry[name]) for name in sorted(registry)]
|
|
260
|
+
|
|
261
|
+
tokens = [tok for tok in re.split(r"\W+", keyword.lower()) if tok]
|
|
262
|
+
scored = [(_score_entry(entry, tokens), entry) for entry in registry.values()]
|
|
263
|
+
scored = [pair for pair in scored if pair[0] > 0]
|
|
264
|
+
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
|
|
265
|
+
return [{**_as_dict(entry), "score": score} for score, entry in scored[:limit]]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def build_biothings_facade(
|
|
269
|
+
registry: dict[str, BioThingsAPIEntry], server_name: str = "smartapi_mcp"
|
|
270
|
+
) -> FastMCP:
|
|
271
|
+
"""Build a FastMCP server exposing generic BioThings tools over ``registry``."""
|
|
272
|
+
server = FastMCP(server_name)
|
|
273
|
+
valid_names = ", ".join(sorted(registry))
|
|
274
|
+
api_help = (
|
|
275
|
+
"BioThings API to target, given as its short name. "
|
|
276
|
+
"Call list_biothings_apis to discover APIs. "
|
|
277
|
+
f"Available: {valid_names}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _entry(api: str) -> BioThingsAPIEntry:
|
|
281
|
+
entry = registry.get(api)
|
|
282
|
+
if entry is None:
|
|
283
|
+
err_msg = (
|
|
284
|
+
f"Unknown api {api!r}. Valid values: {valid_names}. "
|
|
285
|
+
"Use list_biothings_apis to discover available APIs."
|
|
286
|
+
)
|
|
287
|
+
raise ValueError(err_msg)
|
|
288
|
+
_resolve_endpoints(entry)
|
|
289
|
+
return entry
|
|
290
|
+
|
|
291
|
+
async def _request(method: str, url: str, **kwargs) -> dict | list:
|
|
292
|
+
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
|
293
|
+
response = await client.request(method, url, **kwargs)
|
|
294
|
+
response.raise_for_status()
|
|
295
|
+
return response.json()
|
|
296
|
+
|
|
297
|
+
async def list_biothings_apis(
|
|
298
|
+
keyword: str | None = None, limit: int = 10
|
|
299
|
+
) -> list[dict]:
|
|
300
|
+
"""List/search the available BioThings APIs and return matching records."""
|
|
301
|
+
return rank_apis(registry, keyword, limit)
|
|
302
|
+
|
|
303
|
+
async def biothings_query(
|
|
304
|
+
api: str,
|
|
305
|
+
q: str,
|
|
306
|
+
fields: str = "all",
|
|
307
|
+
size: int = 10,
|
|
308
|
+
from_: int = 0,
|
|
309
|
+
) -> dict | list:
|
|
310
|
+
"""Search a BioThings API (GET /query)."""
|
|
311
|
+
entry = _entry(api)
|
|
312
|
+
params = {"q": q, "fields": fields, "size": size, "from": from_}
|
|
313
|
+
return await _request("GET", f"{entry.base_url}/query", params=params)
|
|
314
|
+
|
|
315
|
+
async def biothings_get(
|
|
316
|
+
api: str,
|
|
317
|
+
id: str, # noqa: A002 - `id` is the natural BioThings parameter name
|
|
318
|
+
fields: str = "all",
|
|
319
|
+
) -> dict | list:
|
|
320
|
+
"""Retrieve a single annotation by id (GET /{type}/{id})."""
|
|
321
|
+
entry = _entry(api)
|
|
322
|
+
if not entry.biothing_type:
|
|
323
|
+
err_msg = f"Could not determine the entity type for api {api!r}."
|
|
324
|
+
raise ValueError(err_msg)
|
|
325
|
+
url = f"{entry.base_url}/{entry.biothing_type}/{id}"
|
|
326
|
+
return await _request("GET", url, params={"fields": fields})
|
|
327
|
+
|
|
328
|
+
async def biothings_getbatch(
|
|
329
|
+
api: str,
|
|
330
|
+
ids: list[str],
|
|
331
|
+
fields: str = "all",
|
|
332
|
+
scopes: str | None = None,
|
|
333
|
+
) -> dict | list:
|
|
334
|
+
"""Retrieve annotations for multiple ids (POST /{type})."""
|
|
335
|
+
entry = _entry(api)
|
|
336
|
+
if not entry.biothing_type:
|
|
337
|
+
err_msg = f"Could not determine the entity type for api {api!r}."
|
|
338
|
+
raise ValueError(err_msg)
|
|
339
|
+
data = {"ids": ",".join(ids), "fields": fields}
|
|
340
|
+
if scopes:
|
|
341
|
+
data["scopes"] = scopes
|
|
342
|
+
url = f"{entry.base_url}/{entry.biothing_type}"
|
|
343
|
+
return await _request("POST", url, data=data)
|
|
344
|
+
|
|
345
|
+
async def biothings_fields(api: str) -> dict | list:
|
|
346
|
+
"""List the queryable/returnable fields for a BioThings API."""
|
|
347
|
+
entry = _entry(api)
|
|
348
|
+
return await _request("GET", f"{entry.base_url}/metadata/fields")
|
|
349
|
+
|
|
350
|
+
server.add_tool(
|
|
351
|
+
Tool.from_function(
|
|
352
|
+
list_biothings_apis,
|
|
353
|
+
name="list_biothings_apis",
|
|
354
|
+
description=(
|
|
355
|
+
f"Discover which of the {len(registry)} available BioThings APIs "
|
|
356
|
+
"to use. Optionally pass a keyword to rank by relevance. Returns "
|
|
357
|
+
"API names, titles, descriptions and tags. Call this first, then "
|
|
358
|
+
"pass a returned name as the `api` argument to the other tools."
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
biothings_query.__doc__ = (
|
|
363
|
+
"Search a BioThings API. `api` is the API name (see list_biothings_apis); "
|
|
364
|
+
"`q` is a query string (e.g. 'symbol:CDK2' or 'diabetes')."
|
|
365
|
+
)
|
|
366
|
+
for func, name in (
|
|
367
|
+
(biothings_query, "biothings_query"),
|
|
368
|
+
(biothings_get, "biothings_get"),
|
|
369
|
+
(biothings_getbatch, "biothings_getbatch"),
|
|
370
|
+
(biothings_fields, "biothings_fields"),
|
|
371
|
+
):
|
|
372
|
+
tool = Tool.from_function(func, name=name)
|
|
373
|
+
# Surface the list of valid `api` values in the tool description.
|
|
374
|
+
tool.description = f"{(func.__doc__ or '').strip()}\n\n{api_help}"
|
|
375
|
+
server.add_tool(tool)
|
|
376
|
+
|
|
377
|
+
logger.info(
|
|
378
|
+
f"Built BioThings facade '{server_name}' with {len(registry)} "
|
|
379
|
+
"APIs as 5 generic tools."
|
|
380
|
+
)
|
|
381
|
+
return server
|