smartapi-mcp 0.2.0__tar.gz → 0.3.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.
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/PKG-INFO +58 -4
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/README.md +55 -2
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/pyproject.toml +7 -2
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/smartapi_mcp/__init__.py +13 -2
- smartapi_mcp-0.3.0/smartapi_mcp/biothings.py +381 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/smartapi_mcp/cli.py +96 -15
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/smartapi_mcp/config.py +26 -1
- smartapi_mcp-0.3.0/smartapi_mcp/server.py +256 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/smartapi_mcp/smartapi.py +53 -14
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/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.0}/LICENSE +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/MANIFEST.in +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/requirements-dev.txt +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/requirements.txt +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/setup.cfg +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/setup.py +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/smartapi_mcp/__main__.py +0 -0
- {smartapi_mcp-0.2.0 → smartapi_mcp-0.3.0}/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.0
|
|
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>
|
|
@@ -24,7 +24,8 @@ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
|
24
24
|
Requires-Python: >=3.10
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
License-File: LICENSE
|
|
27
|
-
Requires-Dist: awslabs_openapi_mcp_server
|
|
27
|
+
Requires-Dist: awslabs_openapi_mcp_server<1,>=0.2.12
|
|
28
|
+
Requires-Dist: fastmcp<3,>=2.14
|
|
28
29
|
Provides-Extra: dev
|
|
29
30
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
30
31
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
@@ -60,7 +61,9 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
|
|
|
60
61
|
|
|
61
62
|
- Python 3.10 or higher
|
|
62
63
|
- Network access to SmartAPI registry (https://smart-api.info)
|
|
63
|
-
- Dependencies: `awslabs_openapi_mcp_server>=0.2.12`
|
|
64
|
+
- Dependencies: `awslabs_openapi_mcp_server>=0.2.12,<1` and `fastmcp>=2.14,<3`
|
|
65
|
+
(awslabs 1.x / fastmcp 3.x are not yet supported — see the dependency notes in
|
|
66
|
+
`pyproject.toml`)
|
|
64
67
|
|
|
65
68
|
## Features
|
|
66
69
|
|
|
@@ -70,9 +73,10 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
|
|
|
70
73
|
- 📖 **OpenAPI Validation**: Automatic OpenAPI specification parsing and validation
|
|
71
74
|
- 🛠️ **CLI Interface**: Easy-to-use command-line interface with multiple configuration options
|
|
72
75
|
- 🧬 **Bioinformatics Focus**: Pre-configured API sets for bioinformatics and life sciences
|
|
76
|
+
- 🧩 **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
77
|
- 🎯 **Flexible Configuration**: Support for environment variables, arguments, and configuration files
|
|
74
78
|
- 🚀 **Multiple Transport Modes**: Support for both stdio and HTTP transport protocols
|
|
75
|
-
- 🧪 **Comprehensive Testing**: Full test suite with
|
|
79
|
+
- 🧪 **Comprehensive Testing**: Full test suite with high code coverage
|
|
76
80
|
|
|
77
81
|
## Installation
|
|
78
82
|
|
|
@@ -197,6 +201,51 @@ For other MCP clients that support external MCP servers, you can typically confi
|
|
|
197
201
|
}
|
|
198
202
|
```
|
|
199
203
|
|
|
204
|
+
#### Large API Sets (`biothings_all`) — the BioThings facade
|
|
205
|
+
|
|
206
|
+
All BioThings APIs share the same handful of operations (`/query`,
|
|
207
|
+
`/{type}/{id}`, batch lookups, `/metadata/fields`). Emitting one MCP tool per
|
|
208
|
+
(API × operation) for a large set like `biothings_all` would create 200+
|
|
209
|
+
near-duplicate tools and overflow the client's context window.
|
|
210
|
+
|
|
211
|
+
To avoid this, for large BioThings sets the server exposes a small **fixed set
|
|
212
|
+
of ~5 generic tools** where the target API is a *parameter*:
|
|
213
|
+
|
|
214
|
+
- `list_biothings_apis` — discover/search the available APIs (returns data)
|
|
215
|
+
- `biothings_query` — search any API (`/query`)
|
|
216
|
+
- `biothings_get` / `biothings_getbatch` — annotation lookup by id(s)
|
|
217
|
+
- `biothings_fields` — list queryable fields for an API
|
|
218
|
+
|
|
219
|
+
Because the tool list is small and static, this works on **every** MCP client
|
|
220
|
+
(no dependency on dynamic `tools/list_changed` updates).
|
|
221
|
+
|
|
222
|
+
For a **mixed** set (BioThings + other API types), the server is *hybrid*: the
|
|
223
|
+
BioThings APIs are served through the facade, while any non-BioThings APIs are
|
|
224
|
+
added as faithful per-API tools in the same server — so no APIs are lost.
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"mcpServers": {
|
|
229
|
+
"biothings-all": {
|
|
230
|
+
"command": "uvx",
|
|
231
|
+
"args": ["smartapi-mcp", "--api_set", "biothings_all"]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Control the strategy with `--facade {auto,on,off}` (default `auto`, which
|
|
238
|
+
engages the facade once the BioThings APIs in the set reach
|
|
239
|
+
`--facade-threshold`, default 10) or the `SMARTAPI_FACADE` environment variable.
|
|
240
|
+
Use `--facade off` to force faithful per-API tools regardless of set size.
|
|
241
|
+
|
|
242
|
+
A few BioThings APIs expose extra, non-standard endpoints (e.g. SemmedDB's
|
|
243
|
+
`/query/ngd`) that the generic facade tools can't reach. These are rare, so by
|
|
244
|
+
default they're ignored. Pass `--facade-strict` (or `FACADE_STRICT=1`) to
|
|
245
|
+
inspect each BioThings spec at startup and serve any API that has extra
|
|
246
|
+
endpoints with faithful per-API tools instead — at the cost of slower startup
|
|
247
|
+
(it downloads the specs upfront).
|
|
248
|
+
|
|
200
249
|
#### Development/Testing Setup
|
|
201
250
|
|
|
202
251
|
```json
|
|
@@ -482,6 +531,11 @@ export SMARTAPI_Q="tags.name=biothings"
|
|
|
482
531
|
export SMARTAPI_API_SET="biothings_core"
|
|
483
532
|
export SMARTAPI_EXCLUDE_IDS="exclude_id1,exclude_id2"
|
|
484
533
|
|
|
534
|
+
# BioThings facade strategy (see "Large API Sets" above)
|
|
535
|
+
export SMARTAPI_FACADE="auto" # auto | on | off
|
|
536
|
+
export FACADE_THRESHOLD="10" # min BioThings APIs before 'auto' uses the facade
|
|
537
|
+
export FACADE_STRICT="false" # inspect specs; serve APIs with extra endpoints per-API
|
|
538
|
+
|
|
485
539
|
# Server configuration
|
|
486
540
|
export SERVER_NAME="My SmartAPI MCP Server"
|
|
487
541
|
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.0"
|
|
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"
|
|
@@ -31,7 +31,12 @@ classifiers = [
|
|
|
31
31
|
keywords = ["mcp", "smartapi", "api", "server", "bioinformatics"]
|
|
32
32
|
requires-python = ">=3.10"
|
|
33
33
|
dependencies = [
|
|
34
|
-
|
|
34
|
+
# awslabs 1.x requires fastmcp 3.x, which removed FastMCP.get_tools() and
|
|
35
|
+
# rejects *args tool functions -- incompatible with this package for now.
|
|
36
|
+
# fastmcp is also pinned <3 directly because awslabs 0.2.x allows fastmcp>=2.14
|
|
37
|
+
# with no upper bound and would otherwise resolve to the breaking 3.x.
|
|
38
|
+
"awslabs_openapi_mcp_server>=0.2.12,<1",
|
|
39
|
+
"fastmcp>=2.14,<3",
|
|
35
40
|
]
|
|
36
41
|
|
|
37
42
|
[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.0"
|
|
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
|
|
@@ -6,14 +6,16 @@ Provides CLI commands for running and managing the SmartAPI MCP server.
|
|
|
6
6
|
|
|
7
7
|
import argparse
|
|
8
8
|
import asyncio
|
|
9
|
+
import signal
|
|
9
10
|
import sys
|
|
10
11
|
import traceback
|
|
11
12
|
|
|
12
13
|
from awslabs.openapi_mcp_server import get_format, logger
|
|
13
|
-
from awslabs.openapi_mcp_server.server import get_all_counts
|
|
14
|
+
from awslabs.openapi_mcp_server.server import get_all_counts
|
|
15
|
+
from awslabs.openapi_mcp_server.utils.metrics_provider import metrics
|
|
14
16
|
|
|
15
17
|
from .config import load_config
|
|
16
|
-
from .server import
|
|
18
|
+
from .server import build_server_for_set
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def main():
|
|
@@ -23,8 +25,9 @@ def main():
|
|
|
23
25
|
parser.add_argument(
|
|
24
26
|
"--api_set",
|
|
25
27
|
help=(
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
+
"A predefined set of SmartAPI APIs to include. One of: "
|
|
29
|
+
"'biothings_core' (5 core BioThings APIs), 'biothings_test' "
|
|
30
|
+
"(core + SemmedDB), or 'biothings_all' (all BioThings APIs)."
|
|
28
31
|
),
|
|
29
32
|
)
|
|
30
33
|
parser.add_argument(
|
|
@@ -37,7 +40,10 @@ def main():
|
|
|
37
40
|
)
|
|
38
41
|
parser.add_argument(
|
|
39
42
|
"--smartapi_q",
|
|
40
|
-
help=
|
|
43
|
+
help=(
|
|
44
|
+
"A SmartAPI registry search query selecting which APIs to include, "
|
|
45
|
+
"e.g. 'tags.name:biothings'."
|
|
46
|
+
),
|
|
41
47
|
)
|
|
42
48
|
parser.add_argument(
|
|
43
49
|
"--smartapi_exclude_ids",
|
|
@@ -63,6 +69,37 @@ def main():
|
|
|
63
69
|
"--server_name",
|
|
64
70
|
help='The name of the MCP server, default is "smartapi_mcp".',
|
|
65
71
|
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--facade",
|
|
74
|
+
choices=["auto", "on", "off"],
|
|
75
|
+
default="auto",
|
|
76
|
+
help=(
|
|
77
|
+
"How to expose large BioThings sets. The facade collapses BioThings "
|
|
78
|
+
"APIs into ~5 generic tools (the target API is a parameter); any "
|
|
79
|
+
"non-BioThings APIs in the set are added as per-API tools (hybrid). "
|
|
80
|
+
"'auto' (default): use the facade once there are enough BioThings "
|
|
81
|
+
"APIs (see --facade-threshold). 'on': always use it for BioThings "
|
|
82
|
+
"APIs. 'off': always emit faithful per-API tools for every API."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--facade-threshold",
|
|
87
|
+
type=int,
|
|
88
|
+
default=10,
|
|
89
|
+
help=(
|
|
90
|
+
"Number of BioThings APIs in the set at which 'auto' switches to the "
|
|
91
|
+
"facade (default: 10)."
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--facade-strict",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help=(
|
|
98
|
+
"Inspect BioThings specs and serve any API that has non-standard "
|
|
99
|
+
"endpoints (e.g. SemmedDB's /query/ngd) with faithful per-API tools "
|
|
100
|
+
"instead of the facade. Slower startup (downloads specs upfront)."
|
|
101
|
+
),
|
|
102
|
+
)
|
|
66
103
|
parser.add_argument(
|
|
67
104
|
"--log-level",
|
|
68
105
|
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
@@ -80,20 +117,32 @@ def main():
|
|
|
80
117
|
# Load configuration
|
|
81
118
|
logger.debug("Loading configuration from arguments and environment")
|
|
82
119
|
config = load_config(args)
|
|
120
|
+
|
|
83
121
|
logger.debug("Configuration loaded.")
|
|
84
122
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
123
|
+
try:
|
|
124
|
+
merged_server = asyncio.run(
|
|
125
|
+
build_server_for_set(
|
|
126
|
+
smartapi_q=config.smartapi_q,
|
|
127
|
+
smartapi_id=config.smartapi_id,
|
|
128
|
+
smartapi_ids=config.smartapi_ids,
|
|
129
|
+
smartapi_exclude_ids=config.smartapi_exclude_ids,
|
|
130
|
+
api_set=config.smartapi_api_set,
|
|
131
|
+
server_name=config.server_name,
|
|
132
|
+
facade=getattr(config, "facade", "auto"),
|
|
133
|
+
facade_threshold=getattr(config, "facade_threshold", 10),
|
|
134
|
+
facade_strict=getattr(config, "facade_strict", False),
|
|
135
|
+
)
|
|
93
136
|
)
|
|
94
|
-
|
|
137
|
+
except ValueError as e:
|
|
138
|
+
logger.error(f"Cannot start server: {e}")
|
|
139
|
+
logger.error(
|
|
140
|
+
"Specify which APIs to serve with one of: --api_set, --smartapi_id, "
|
|
141
|
+
"--smartapi_ids, or --smartapi_q (see --help)."
|
|
142
|
+
)
|
|
143
|
+
sys.exit(1)
|
|
95
144
|
|
|
96
|
-
# Set up signal handlers
|
|
145
|
+
# Set up signal handlers (local implementation avoids sys.exit in handler)
|
|
97
146
|
setup_signal_handlers()
|
|
98
147
|
|
|
99
148
|
try:
|
|
@@ -134,5 +183,37 @@ def main():
|
|
|
134
183
|
merged_server.run()
|
|
135
184
|
|
|
136
185
|
|
|
186
|
+
def setup_signal_handlers() -> None:
|
|
187
|
+
"""
|
|
188
|
+
Set up signal handlers for graceful shutdown without sys.exit.
|
|
189
|
+
Modified from awslabs.openapi_mcp_server.server.setup_signal_handlers
|
|
190
|
+
Original version calls sys.exit in the handler which can cause issues.
|
|
191
|
+
"""
|
|
192
|
+
handled = {"done": False}
|
|
193
|
+
|
|
194
|
+
def _handler(sig, frame): # noqa: ARG001
|
|
195
|
+
if handled["done"]:
|
|
196
|
+
return
|
|
197
|
+
handled["done"] = True
|
|
198
|
+
|
|
199
|
+
logger.debug("Received signal %s, shutting down gracefully...", sig)
|
|
200
|
+
|
|
201
|
+
# Log final metrics
|
|
202
|
+
summary = metrics.get_summary()
|
|
203
|
+
logger.info(f"Final metrics: {summary}")
|
|
204
|
+
|
|
205
|
+
if sig == signal.SIGINT:
|
|
206
|
+
logger.info("Process Interrupted, Shutting down gracefully...")
|
|
207
|
+
|
|
208
|
+
logger.info("Shutdown complete.")
|
|
209
|
+
|
|
210
|
+
# Restore default handler and re-raise signal to let runtime unwind cleanly
|
|
211
|
+
signal.signal(sig, signal.SIG_DFL)
|
|
212
|
+
signal.raise_signal(sig)
|
|
213
|
+
|
|
214
|
+
signal.signal(signal.SIGTERM, _handler)
|
|
215
|
+
signal.signal(signal.SIGINT, _handler)
|
|
216
|
+
|
|
217
|
+
|
|
137
218
|
if __name__ == "__main__":
|
|
138
219
|
main()
|
|
@@ -14,7 +14,21 @@ class Config(_config.Config):
|
|
|
14
14
|
smartapi_exclude_ids: list[str] | None = None
|
|
15
15
|
smartapi_q: str = ""
|
|
16
16
|
smartapi_api_set: str = ""
|
|
17
|
-
server_name: str = "
|
|
17
|
+
server_name: str = "smartapi_mcp"
|
|
18
|
+
facade: str = "auto"
|
|
19
|
+
facade_threshold: int = 10
|
|
20
|
+
facade_strict: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_bool(value: str) -> bool:
|
|
24
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_int(value: str, default: int) -> int:
|
|
28
|
+
try:
|
|
29
|
+
return int(value)
|
|
30
|
+
except (TypeError, ValueError):
|
|
31
|
+
return default
|
|
18
32
|
|
|
19
33
|
|
|
20
34
|
def load_config(args: Any = None) -> Config:
|
|
@@ -32,6 +46,11 @@ def load_config(args: Any = None) -> Config:
|
|
|
32
46
|
),
|
|
33
47
|
"SMARTAPI_Q": (lambda v: setattr(config, "smartapi_q", v)),
|
|
34
48
|
"SMARTAPI_API_SET": (lambda v: setattr(config, "smartapi_api_set", v)),
|
|
49
|
+
"SMARTAPI_FACADE": (lambda v: setattr(config, "facade", v.strip().lower())),
|
|
50
|
+
"FACADE_THRESHOLD": (
|
|
51
|
+
lambda v: setattr(config, "facade_threshold", _parse_int(v, 10))
|
|
52
|
+
),
|
|
53
|
+
"FACADE_STRICT": (lambda v: setattr(config, "facade_strict", _parse_bool(v))),
|
|
35
54
|
"SERVER_NAME": (lambda v: setattr(config, "server_name", v)),
|
|
36
55
|
}
|
|
37
56
|
|
|
@@ -83,6 +102,12 @@ def load_config(args: Any = None) -> Config:
|
|
|
83
102
|
if hasattr(args, "server_name") and args.server_name:
|
|
84
103
|
logger.debug(f"Setting MCP Server name from arguments: {args.server_name}")
|
|
85
104
|
config.server_name = args.server_name
|
|
105
|
+
if getattr(args, "facade", None):
|
|
106
|
+
config.facade = str(args.facade).strip().lower()
|
|
107
|
+
if getattr(args, "facade_threshold", None):
|
|
108
|
+
config.facade_threshold = int(args.facade_threshold)
|
|
109
|
+
if getattr(args, "facade_strict", False):
|
|
110
|
+
config.facade_strict = True
|
|
86
111
|
if hasattr(args, "transport") and args.transport:
|
|
87
112
|
logger.debug(
|
|
88
113
|
f"Setting MCP Server transport mode from arguments: {args.transport}"
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SmartAPI MCP Server
|
|
3
|
+
|
|
4
|
+
Main MCP server implementation for SmartAPI integration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from awslabs.openapi_mcp_server import logger
|
|
10
|
+
from awslabs.openapi_mcp_server.api.config import Config
|
|
11
|
+
from awslabs.openapi_mcp_server.server import create_mcp_server_async
|
|
12
|
+
from fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
# Import BioThings generic-facade builder
|
|
15
|
+
from .biothings import build_biothings_facade, build_registry, partition_biothings
|
|
16
|
+
|
|
17
|
+
# Import from smartapi module - avoiding circular imports
|
|
18
|
+
from .smartapi import (
|
|
19
|
+
get_base_server_url,
|
|
20
|
+
get_predefined_api_set,
|
|
21
|
+
get_smartapi_ids,
|
|
22
|
+
load_api_spec,
|
|
23
|
+
smartapi_spec_url,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def get_mcp_server(smartapi_id: str) -> FastMCP:
|
|
28
|
+
config = Config(
|
|
29
|
+
api_spec_url=smartapi_spec_url.format(smartapi_id=smartapi_id),
|
|
30
|
+
)
|
|
31
|
+
openapi_spec = load_api_spec(smartapi_id)
|
|
32
|
+
base_server_url = get_base_server_url(openapi_spec)
|
|
33
|
+
config.api_base_url = base_server_url
|
|
34
|
+
|
|
35
|
+
return await create_mcp_server_async(config)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _merge_servers_into(
|
|
39
|
+
target: FastMCP, list_of_servers: list[FastMCP]
|
|
40
|
+
) -> FastMCP:
|
|
41
|
+
"""Add the tools/prompts of each server to ``target``, prefixed by API name.
|
|
42
|
+
|
|
43
|
+
Tool and prompt names are prefixed with the source server's (API) name to
|
|
44
|
+
avoid conflicts. ``target`` is mutated in place and returned.
|
|
45
|
+
"""
|
|
46
|
+
for server in list_of_servers:
|
|
47
|
+
api_name = re.sub(
|
|
48
|
+
r"[^a-z0-9_-]", "_", getattr(server, "name", "unknown_api").lower()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
tools = await server.get_tools()
|
|
52
|
+
if tools:
|
|
53
|
+
for original_name, tool in tools.items():
|
|
54
|
+
# Rename the tool by prefixing with API name
|
|
55
|
+
tool.name = f"{api_name}_{original_name}"
|
|
56
|
+
target.add_tool(tool)
|
|
57
|
+
else:
|
|
58
|
+
err_msg = f"Server {server} does not have accessible tools."
|
|
59
|
+
raise AttributeError(err_msg)
|
|
60
|
+
|
|
61
|
+
# Merge prompts
|
|
62
|
+
prompts = await server.get_prompts()
|
|
63
|
+
if prompts:
|
|
64
|
+
for original_name, prompt in prompts.items():
|
|
65
|
+
# Rename the prompt by prefixing with API name
|
|
66
|
+
prompt.name = f"{api_name}_{original_name}"
|
|
67
|
+
target.add_prompt(prompt)
|
|
68
|
+
logger.debug(f"Merged {len(prompts)} prompts from {api_name}")
|
|
69
|
+
|
|
70
|
+
return target
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def merge_mcp_servers(
|
|
74
|
+
list_of_servers: list[FastMCP], merged_name: str = "merged_mcp"
|
|
75
|
+
) -> FastMCP:
|
|
76
|
+
"""
|
|
77
|
+
Merges a list of FastMCP instances into
|
|
78
|
+
a single FastMCP instance by combining their
|
|
79
|
+
tools, prefixing tool names with the server's
|
|
80
|
+
name (API name) to avoid conflicts.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
list_of_servers: List of FastMCP instances to merge.
|
|
84
|
+
merged_name: Name for the merged FastMCP instance.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A new FastMCP instance with renamed tools from all input servers.
|
|
88
|
+
"""
|
|
89
|
+
return await _merge_servers_into(FastMCP(merged_name), list_of_servers)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def get_merged_mcp_server(
|
|
93
|
+
smartapi_q: str | None = None,
|
|
94
|
+
smartapi_id: str | None = None,
|
|
95
|
+
smartapi_ids: list[str] | None = None,
|
|
96
|
+
smartapi_exclude_ids: list[str] | None = None,
|
|
97
|
+
api_set: str | None = None,
|
|
98
|
+
server_name: str = "smartapi_mcp",
|
|
99
|
+
) -> FastMCP:
|
|
100
|
+
logger.debug(f"api_set: {api_set}")
|
|
101
|
+
if api_set:
|
|
102
|
+
api_set_args = get_predefined_api_set(api_set)
|
|
103
|
+
if "smartapi_ids" in api_set_args:
|
|
104
|
+
smartapi_ids = api_set_args["smartapi_ids"]
|
|
105
|
+
if "smartapi_q" in api_set_args:
|
|
106
|
+
smartapi_q = api_set_args["smartapi_q"]
|
|
107
|
+
if "smartapi_exclude_ids" in api_set_args:
|
|
108
|
+
smartapi_exclude_ids = api_set_args["smartapi_exclude_ids"]
|
|
109
|
+
logger.debug(f"api_set_args: {api_set_args}")
|
|
110
|
+
logger.debug(f"smartapi_ids: {smartapi_ids}")
|
|
111
|
+
logger.debug(f"smartapi_q: {smartapi_q}")
|
|
112
|
+
logger.debug(f"smartapi_exclude_ids: {smartapi_exclude_ids}")
|
|
113
|
+
if smartapi_q:
|
|
114
|
+
smartapi_ids = await get_smartapi_ids(smartapi_q)
|
|
115
|
+
if smartapi_id:
|
|
116
|
+
smartapi_ids = [smartapi_id]
|
|
117
|
+
if smartapi_ids:
|
|
118
|
+
smartapi_ids = list(set(smartapi_ids))
|
|
119
|
+
if not smartapi_ids:
|
|
120
|
+
err_msg = "No SmartAPI IDs provided or found with the given query."
|
|
121
|
+
raise ValueError(err_msg)
|
|
122
|
+
smartapi_exclude_ids = smartapi_exclude_ids or []
|
|
123
|
+
list_of_servers = [
|
|
124
|
+
await get_mcp_server(sid)
|
|
125
|
+
for sid in smartapi_ids
|
|
126
|
+
if sid not in smartapi_exclude_ids
|
|
127
|
+
]
|
|
128
|
+
merged_server = await merge_mcp_servers(list_of_servers, server_name)
|
|
129
|
+
logger.info(f"Merged {len(list_of_servers)} APIs into one MCP server.")
|
|
130
|
+
return merged_server
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _resolve_smartapi_ids(
|
|
134
|
+
smartapi_q: str | None = None,
|
|
135
|
+
smartapi_id: str | None = None,
|
|
136
|
+
smartapi_ids: list[str] | None = None,
|
|
137
|
+
smartapi_exclude_ids: list[str] | None = None,
|
|
138
|
+
api_set: str | None = None,
|
|
139
|
+
) -> list[str]:
|
|
140
|
+
"""Resolve the various ID sources into a deduped, exclusion-filtered list."""
|
|
141
|
+
if api_set:
|
|
142
|
+
api_set_args = get_predefined_api_set(api_set)
|
|
143
|
+
smartapi_ids = api_set_args.get("smartapi_ids", smartapi_ids)
|
|
144
|
+
smartapi_q = api_set_args.get("smartapi_q", smartapi_q)
|
|
145
|
+
smartapi_exclude_ids = api_set_args.get(
|
|
146
|
+
"smartapi_exclude_ids", smartapi_exclude_ids
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if smartapi_q:
|
|
150
|
+
smartapi_ids = await get_smartapi_ids(smartapi_q)
|
|
151
|
+
if smartapi_id:
|
|
152
|
+
smartapi_ids = [smartapi_id]
|
|
153
|
+
|
|
154
|
+
if smartapi_ids:
|
|
155
|
+
# Dedupe while preserving order (stable, unlike set()).
|
|
156
|
+
smartapi_ids = list(dict.fromkeys(smartapi_ids))
|
|
157
|
+
if not smartapi_ids:
|
|
158
|
+
err_msg = "No SmartAPI IDs provided or found with the given query."
|
|
159
|
+
raise ValueError(err_msg)
|
|
160
|
+
|
|
161
|
+
exclude = set(smartapi_exclude_ids or [])
|
|
162
|
+
return [sid for sid in smartapi_ids if sid not in exclude]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def build_server_for_set(
|
|
166
|
+
smartapi_q: str | None = None,
|
|
167
|
+
smartapi_id: str | None = None,
|
|
168
|
+
smartapi_ids: list[str] | None = None,
|
|
169
|
+
smartapi_exclude_ids: list[str] | None = None,
|
|
170
|
+
api_set: str | None = None,
|
|
171
|
+
server_name: str = "smartapi_mcp",
|
|
172
|
+
*,
|
|
173
|
+
facade: str = "auto",
|
|
174
|
+
facade_threshold: int = 10,
|
|
175
|
+
facade_strict: bool = False,
|
|
176
|
+
) -> FastMCP:
|
|
177
|
+
"""Build the MCP server for an API set, picking the right strategy.
|
|
178
|
+
|
|
179
|
+
For large BioThings sets the **generic facade** (a fixed ~5 tools where the
|
|
180
|
+
target API is a parameter) is used to avoid the 200+ per-API tool explosion.
|
|
181
|
+
For a **mixed** set, the result is a *hybrid* server: the BioThings APIs are
|
|
182
|
+
served through the facade while any non-BioThings APIs are added as faithful
|
|
183
|
+
per-API tools in the same server, so nothing is lost. ``facade`` is one of
|
|
184
|
+
``"auto"`` (facade when enough APIs in the set are BioThings),
|
|
185
|
+
``"on"`` (force facade for the BioThings subset), or ``"off"`` (always emit
|
|
186
|
+
per-API tools for every API).
|
|
187
|
+
|
|
188
|
+
``facade_strict`` (default ``False``) controls handling of the rare
|
|
189
|
+
BioThings APIs that expose endpoints beyond the standard interface (e.g.
|
|
190
|
+
SemmedDB's ``/query/ngd``). When ``False``, all BioThings APIs go through the
|
|
191
|
+
facade and those extra endpoints are not reachable (fast startup, no spec
|
|
192
|
+
downloads). When ``True``, each BioThings spec is inspected and any API with
|
|
193
|
+
extra endpoints is served with faithful per-API tools instead (slower
|
|
194
|
+
startup; downloads specs upfront).
|
|
195
|
+
"""
|
|
196
|
+
available_ids = await _resolve_smartapi_ids(
|
|
197
|
+
smartapi_q=smartapi_q,
|
|
198
|
+
smartapi_id=smartapi_id,
|
|
199
|
+
smartapi_ids=smartapi_ids,
|
|
200
|
+
smartapi_exclude_ids=smartapi_exclude_ids,
|
|
201
|
+
api_set=api_set,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if facade != "off":
|
|
205
|
+
registry = await build_registry(available_ids)
|
|
206
|
+
biothings = {
|
|
207
|
+
name: entry for name, entry in registry.items() if "biothings" in entry.tags
|
|
208
|
+
}
|
|
209
|
+
if facade == "on" and not biothings:
|
|
210
|
+
logger.warning(
|
|
211
|
+
"facade='on' but no BioThings APIs were found in the set; "
|
|
212
|
+
"falling back to per-API tools."
|
|
213
|
+
)
|
|
214
|
+
use_facade = bool(biothings) and (
|
|
215
|
+
facade == "on" or len(biothings) >= facade_threshold
|
|
216
|
+
)
|
|
217
|
+
if use_facade:
|
|
218
|
+
if facade_strict:
|
|
219
|
+
# Inspect specs: only fully-standard BioThings APIs go in the
|
|
220
|
+
# facade; ones with extra endpoints fall back to per-API tools.
|
|
221
|
+
facade_entries, extra_bt_ids = await partition_biothings(biothings)
|
|
222
|
+
else:
|
|
223
|
+
# Fast path: assume every BioThings API is fully standard.
|
|
224
|
+
facade_entries, extra_bt_ids = biothings, []
|
|
225
|
+
non_biothings_ids = [
|
|
226
|
+
entry.smartapi_id
|
|
227
|
+
for name, entry in registry.items()
|
|
228
|
+
if name not in biothings
|
|
229
|
+
]
|
|
230
|
+
per_api_ids = non_biothings_ids + extra_bt_ids
|
|
231
|
+
|
|
232
|
+
if facade_entries:
|
|
233
|
+
server = build_biothings_facade(facade_entries, server_name)
|
|
234
|
+
if per_api_ids:
|
|
235
|
+
logger.info(
|
|
236
|
+
f"Hybrid server: facade over {len(facade_entries)} "
|
|
237
|
+
f"BioThings API(s) + per-API tools for "
|
|
238
|
+
f"{len(per_api_ids)} other API(s)."
|
|
239
|
+
)
|
|
240
|
+
extra_servers = [await get_mcp_server(sid) for sid in per_api_ids]
|
|
241
|
+
await _merge_servers_into(server, extra_servers)
|
|
242
|
+
else:
|
|
243
|
+
logger.info(
|
|
244
|
+
f"Using BioThings facade for {len(facade_entries)} APIs "
|
|
245
|
+
f"(server_name={server_name})."
|
|
246
|
+
)
|
|
247
|
+
return server
|
|
248
|
+
logger.info(
|
|
249
|
+
"No APIs qualified for the BioThings facade; using per-API tools."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
logger.info(f"Using per-API tools for {len(available_ids)} APIs.")
|
|
253
|
+
return await get_merged_mcp_server(
|
|
254
|
+
smartapi_ids=available_ids,
|
|
255
|
+
server_name=server_name,
|
|
256
|
+
)
|
|
@@ -12,27 +12,65 @@ from awslabs.openapi_mcp_server.api.config import Config
|
|
|
12
12
|
from awslabs.openapi_mcp_server.utils.openapi import load_openapi_spec
|
|
13
13
|
from awslabs.openapi_mcp_server.utils.openapi_validator import validate_openapi_spec
|
|
14
14
|
|
|
15
|
-
smartapi_query_url = "https://smart-api.info/api/query
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
smartapi_query_url = "https://smart-api.info/api/query"
|
|
16
|
+
smartapi_spec_url = "https://smart-api.info/api/metadata/{smartapi_id}"
|
|
17
|
+
|
|
18
|
+
# Default timeout (seconds) for all SmartAPI registry HTTP calls.
|
|
19
|
+
HTTP_TIMEOUT = 30.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def get_smartapi_registry(
|
|
23
|
+
q: str | None = None, ids: list[str] | None = None
|
|
24
|
+
) -> list[dict]:
|
|
25
|
+
"""Query the SmartAPI registry and return metadata for matching APIs.
|
|
26
|
+
|
|
27
|
+
Returns a list of ``{"_id", "title", "description", "tags"}`` dicts. Pass
|
|
28
|
+
either a query string ``q`` or an explicit list of ``ids`` (which is turned
|
|
29
|
+
into an ``_id:(...)`` query so a whole set is fetched in a single request).
|
|
30
|
+
"""
|
|
31
|
+
if ids:
|
|
32
|
+
q = "_id:({})".format(" OR ".join(ids))
|
|
33
|
+
if not q:
|
|
34
|
+
err_msg = "Either a query string or a list of IDs must be provided."
|
|
35
|
+
raise ValueError(err_msg)
|
|
21
36
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
params = {
|
|
38
|
+
"q": q,
|
|
39
|
+
"fields": "info.title,info.description,tags",
|
|
40
|
+
"size": 500,
|
|
41
|
+
"raw": 1,
|
|
42
|
+
}
|
|
43
|
+
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
|
44
|
+
response = await client.get(smartapi_query_url, params=params)
|
|
25
45
|
response.raise_for_status()
|
|
26
46
|
data = response.json()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
|
|
48
|
+
entries: list[dict] = []
|
|
49
|
+
for hit in data.get("hits", []):
|
|
50
|
+
info = hit.get("info", {}) or {}
|
|
51
|
+
tags = [
|
|
52
|
+
tag.get("name", "") for tag in hit.get("tags", []) if isinstance(tag, dict)
|
|
53
|
+
]
|
|
54
|
+
entries.append(
|
|
55
|
+
{
|
|
56
|
+
"_id": hit.get("_id", ""),
|
|
57
|
+
"title": info.get("title", ""),
|
|
58
|
+
"description": info.get("description", ""),
|
|
59
|
+
"tags": [tag for tag in tags if tag],
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return entries
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def get_smartapi_ids(q: str) -> list[str]:
|
|
66
|
+
"""Give a query string, return a list of SmartAPI IDs matching the query."""
|
|
67
|
+
entries = await get_smartapi_registry(q=q)
|
|
68
|
+
return [entry["_id"] for entry in entries if entry["_id"]]
|
|
31
69
|
|
|
32
70
|
|
|
33
71
|
def load_api_spec(smartapi_id: str) -> dict:
|
|
34
72
|
config = Config(
|
|
35
|
-
api_spec_url=
|
|
73
|
+
api_spec_url=smartapi_spec_url.format(smartapi_id=smartapi_id),
|
|
36
74
|
)
|
|
37
75
|
api_spec = load_openapi_spec(url=config.api_spec_url)
|
|
38
76
|
|
|
@@ -108,6 +146,7 @@ def get_predefined_api_set(api_set: str) -> dict:
|
|
|
108
146
|
"cc857d5b7c8b7609b5bbb38ff990bfff", # GO Biological Process API
|
|
109
147
|
"f339b28426e7bf72028f60feefcd7465", # GO Cellular Component API
|
|
110
148
|
"34bad236d77bea0a0ee6c6cba5be54a6", # GO Molecular Function API
|
|
149
|
+
"27a5b60716c3a401f2c021a5b718c5b1", # SmartAPI registry API
|
|
111
150
|
],
|
|
112
151
|
}
|
|
113
152
|
err_msg = f"Unknown API set: {api_set}"
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
SmartAPI MCP Server
|
|
3
|
-
|
|
4
|
-
Main MCP server implementation for SmartAPI integration.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import re
|
|
8
|
-
|
|
9
|
-
from awslabs.openapi_mcp_server import logger
|
|
10
|
-
from awslabs.openapi_mcp_server.api.config import Config
|
|
11
|
-
from awslabs.openapi_mcp_server.server import create_mcp_server_async
|
|
12
|
-
from fastmcp import FastMCP
|
|
13
|
-
|
|
14
|
-
from .smartapi import (
|
|
15
|
-
get_base_server_url,
|
|
16
|
-
get_predefined_api_set,
|
|
17
|
-
get_smartapi_ids,
|
|
18
|
-
load_api_spec,
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
async def get_mcp_server(smartapi_id: str) -> FastMCP:
|
|
23
|
-
config = Config(
|
|
24
|
-
api_spec_url=f"https://smart-api.info/api/metadata/{smartapi_id}",
|
|
25
|
-
)
|
|
26
|
-
openapi_spec = load_api_spec(smartapi_id)
|
|
27
|
-
base_server_url = get_base_server_url(openapi_spec)
|
|
28
|
-
config.api_base_url = base_server_url
|
|
29
|
-
|
|
30
|
-
return await create_mcp_server_async(config)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
async def merge_mcp_servers(
|
|
34
|
-
list_of_servers: list[FastMCP], merged_name: str = "merged_mcp"
|
|
35
|
-
) -> FastMCP:
|
|
36
|
-
"""
|
|
37
|
-
Merges a list of FastMCP instances into
|
|
38
|
-
a single FastMCP instance by combining their
|
|
39
|
-
tools, prefixing tool names with the server's
|
|
40
|
-
name (API name) to avoid conflicts.
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
list_of_servers: List of FastMCP instances to merge.
|
|
44
|
-
merged_name: Name for the merged FastMCP instance.
|
|
45
|
-
|
|
46
|
-
Returns:
|
|
47
|
-
A new FastMCP instance with renamed tools from all input servers.
|
|
48
|
-
"""
|
|
49
|
-
merged_mcp = FastMCP(merged_name)
|
|
50
|
-
|
|
51
|
-
for server in list_of_servers:
|
|
52
|
-
api_name = re.sub(
|
|
53
|
-
r"[^a-z0-9_-]", "_", getattr(server, "name", "unknown_api").lower()
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
tools = await server.get_tools()
|
|
57
|
-
if tools:
|
|
58
|
-
for original_name, tool in tools.items():
|
|
59
|
-
# Rename the tool by prefixing with API name
|
|
60
|
-
new_name = f"{api_name}_{original_name}"
|
|
61
|
-
tool.name = new_name # Modify the tool's name attribute
|
|
62
|
-
|
|
63
|
-
# Add the renamed tool to the merged instance
|
|
64
|
-
merged_mcp.add_tool(tool)
|
|
65
|
-
else:
|
|
66
|
-
err_msg = f"Server {server} does not have accessible tools."
|
|
67
|
-
raise AttributeError(err_msg)
|
|
68
|
-
|
|
69
|
-
# Merge prompts
|
|
70
|
-
prompts = await server.get_prompts()
|
|
71
|
-
if prompts:
|
|
72
|
-
for original_name, prompt in prompts.items():
|
|
73
|
-
# Rename the prompt by prefixing with API name
|
|
74
|
-
new_name = f"{api_name}_{original_name}"
|
|
75
|
-
prompt.name = new_name # Modify the prompt's name attribute
|
|
76
|
-
# Add the renamed prompt to the merged instance
|
|
77
|
-
merged_mcp.add_prompt(prompt)
|
|
78
|
-
logger.debug(f"Merged {len(prompts)} prompts from {api_name}")
|
|
79
|
-
|
|
80
|
-
return merged_mcp
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
async def get_merged_mcp_server(
|
|
84
|
-
smartapi_q: str | None = None,
|
|
85
|
-
smartapi_id: str | None = None,
|
|
86
|
-
smartapi_ids: list[str] | None = None,
|
|
87
|
-
smartapi_exclude_ids: list[str] | None = None,
|
|
88
|
-
api_set: str | None = None,
|
|
89
|
-
server_name: str = "smartapi_mcp",
|
|
90
|
-
) -> FastMCP:
|
|
91
|
-
logger.debug(f"api_set: {api_set}")
|
|
92
|
-
if api_set:
|
|
93
|
-
api_set_args = get_predefined_api_set(api_set)
|
|
94
|
-
if "smartapi_ids" in api_set_args:
|
|
95
|
-
smartapi_ids = api_set_args["smartapi_ids"]
|
|
96
|
-
if "smartapi_q" in api_set_args:
|
|
97
|
-
smartapi_q = api_set_args["smartapi_q"]
|
|
98
|
-
if "smartapi_exclude_ids" in api_set_args:
|
|
99
|
-
smartapi_exclude_ids = api_set_args["smartapi_exclude_ids"]
|
|
100
|
-
logger.debug(f"api_set_args: {api_set_args}")
|
|
101
|
-
logger.debug(f"smartapi_ids: {smartapi_ids}")
|
|
102
|
-
logger.debug(f"smartapi_q: {smartapi_q}")
|
|
103
|
-
logger.debug(f"smartapi_exclude_ids: {smartapi_exclude_ids}")
|
|
104
|
-
if smartapi_q:
|
|
105
|
-
smartapi_ids = await get_smartapi_ids(smartapi_q)
|
|
106
|
-
if smartapi_id:
|
|
107
|
-
smartapi_ids = [smartapi_id]
|
|
108
|
-
if smartapi_ids:
|
|
109
|
-
smartapi_ids = list(set(smartapi_ids))
|
|
110
|
-
if not smartapi_ids:
|
|
111
|
-
err_msg = "No SmartAPI IDs provided or found with the given query."
|
|
112
|
-
raise ValueError(err_msg)
|
|
113
|
-
smartapi_exclude_ids = smartapi_exclude_ids or []
|
|
114
|
-
list_of_servers = [
|
|
115
|
-
await get_mcp_server(sid)
|
|
116
|
-
for sid in smartapi_ids
|
|
117
|
-
if sid not in smartapi_exclude_ids
|
|
118
|
-
]
|
|
119
|
-
merged_server = await merge_mcp_servers(list_of_servers, server_name)
|
|
120
|
-
logger.info(f"Merged {len(list_of_servers)} APIs into one MCP server.")
|
|
121
|
-
return merged_server
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|