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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartapi-mcp
3
- Version: 0.2.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>=0.2.12
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 99% code coverage
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 99% code coverage
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.2.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
- "awslabs_openapi_mcp_server>=0.2.12",
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.2.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 .server import get_mcp_server, get_merged_mcp_server, merge_mcp_servers
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, setup_signal_handlers
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 get_merged_mcp_server
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
- "the set of predefined SmartAPI APIs to include, e.g. 'biothings_core' "
27
- "or 'biothings'."
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="Pass a query string for a list of SmartAPIs to create a MCP server.",
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
- merged_server = asyncio.run(
86
- get_merged_mcp_server(
87
- smartapi_q=config.smartapi_q,
88
- smartapi_id=config.smartapi_id,
89
- smartapi_ids=config.smartapi_ids,
90
- smartapi_exclude_ids=config.smartapi_exclude_ids,
91
- api_set=config.smartapi_api_set,
92
- server_name=config.server_name,
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 = "smartapi-mcp"
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?q={q}&fields=_id&size=500&raw=1"
16
-
17
-
18
- async def get_smartapi_ids(q: str) -> list:
19
- """Give a query string, return a list of SmartAPI IDs matching the query."""
20
- _url = smartapi_query_url.format(q=q)
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
- smartapi_ids = []
23
- async with httpx.AsyncClient() as client:
24
- response = await client.get(_url)
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
- for api in data["hits"]:
28
- smartapi_id = api["_id"]
29
- smartapi_ids.append(smartapi_id)
30
- return smartapi_ids
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=f"https://smart-api.info/api/metadata/{smartapi_id}",
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}"
@@ -7,6 +7,7 @@ requirements.txt
7
7
  setup.py
8
8
  smartapi_mcp/__init__.py
9
9
  smartapi_mcp/__main__.py
10
+ smartapi_mcp/biothings.py
10
11
  smartapi_mcp/cli.py
11
12
  smartapi_mcp/config.py
12
13
  smartapi_mcp/py.typed
@@ -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