smartapi-mcp 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartapi-mcp
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Create MCP servers for one or multiple APIs registered in SmartAPI registry
5
5
  Author-email: BioThings Team <help@biothings.io>
6
6
  Maintainer-email: BioThings Team <help@biothings.io>
@@ -18,13 +18,15 @@ Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
21
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
23
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
23
24
  Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
24
25
  Requires-Python: >=3.10
25
26
  Description-Content-Type: text/markdown
26
27
  License-File: LICENSE
27
- Requires-Dist: awslabs_openapi_mcp_server>=0.2.12
28
+ Requires-Dist: awslabs_openapi_mcp_server<1,>=0.2.12
29
+ Requires-Dist: fastmcp<3,>=2.14
28
30
  Provides-Extra: dev
29
31
  Requires-Dist: pytest>=7.0.0; extra == "dev"
30
32
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -60,7 +62,9 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
60
62
 
61
63
  - Python 3.10 or higher
62
64
  - Network access to SmartAPI registry (https://smart-api.info)
63
- - Dependencies: `awslabs_openapi_mcp_server>=0.2.12`
65
+ - Dependencies: `awslabs_openapi_mcp_server>=0.2.12,<1` and `fastmcp>=2.14,<3`
66
+ (awslabs 1.x / fastmcp 3.x are not yet supported — see the dependency notes in
67
+ `pyproject.toml`)
64
68
 
65
69
  ## Features
66
70
 
@@ -70,9 +74,10 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
70
74
  - 📖 **OpenAPI Validation**: Automatic OpenAPI specification parsing and validation
71
75
  - 🛠️ **CLI Interface**: Easy-to-use command-line interface with multiple configuration options
72
76
  - 🧬 **Bioinformatics Focus**: Pre-configured API sets for bioinformatics and life sciences
77
+ - 🧩 **Scales to large API sets**: Large BioThings sets (e.g. `biothings_all`, 200+ operations) automatically collapse to ~5 generic tools, avoiding client context overflow without relying on client-side tool deferral
73
78
  - 🎯 **Flexible Configuration**: Support for environment variables, arguments, and configuration files
74
79
  - 🚀 **Multiple Transport Modes**: Support for both stdio and HTTP transport protocols
75
- - 🧪 **Comprehensive Testing**: Full test suite with 99% code coverage
80
+ - 🧪 **Comprehensive Testing**: Full test suite with high code coverage
76
81
 
77
82
  ## Installation
78
83
 
@@ -197,6 +202,51 @@ For other MCP clients that support external MCP servers, you can typically confi
197
202
  }
198
203
  ```
199
204
 
205
+ #### Large API Sets (`biothings_all`) — the BioThings facade
206
+
207
+ All BioThings APIs share the same handful of operations (`/query`,
208
+ `/{type}/{id}`, batch lookups, `/metadata/fields`). Emitting one MCP tool per
209
+ (API × operation) for a large set like `biothings_all` would create 200+
210
+ near-duplicate tools and overflow the client's context window.
211
+
212
+ To avoid this, for large BioThings sets the server exposes a small **fixed set
213
+ of ~5 generic tools** where the target API is a *parameter*:
214
+
215
+ - `list_biothings_apis` — discover/search the available APIs (returns data)
216
+ - `biothings_query` — search any API (`/query`)
217
+ - `biothings_get` / `biothings_getbatch` — annotation lookup by id(s)
218
+ - `biothings_fields` — list queryable fields for an API
219
+
220
+ Because the tool list is small and static, this works on **every** MCP client
221
+ (no dependency on dynamic `tools/list_changed` updates).
222
+
223
+ For a **mixed** set (BioThings + other API types), the server is *hybrid*: the
224
+ BioThings APIs are served through the facade, while any non-BioThings APIs are
225
+ added as faithful per-API tools in the same server — so no APIs are lost.
226
+
227
+ ```json
228
+ {
229
+ "mcpServers": {
230
+ "biothings-all": {
231
+ "command": "uvx",
232
+ "args": ["smartapi-mcp", "--api_set", "biothings_all"]
233
+ }
234
+ }
235
+ }
236
+ ```
237
+
238
+ Control the strategy with `--facade {auto,on,off}` (default `auto`, which
239
+ engages the facade once the BioThings APIs in the set reach
240
+ `--facade-threshold`, default 10) or the `SMARTAPI_FACADE` environment variable.
241
+ Use `--facade off` to force faithful per-API tools regardless of set size.
242
+
243
+ A few BioThings APIs expose extra, non-standard endpoints (e.g. SemmedDB's
244
+ `/query/ngd`) that the generic facade tools can't reach. These are rare, so by
245
+ default they're ignored. Pass `--facade-strict` (or `FACADE_STRICT=1`) to
246
+ inspect each BioThings spec at startup and serve any API that has extra
247
+ endpoints with faithful per-API tools instead — at the cost of slower startup
248
+ (it downloads the specs upfront).
249
+
200
250
  #### Development/Testing Setup
201
251
 
202
252
  ```json
@@ -482,6 +532,11 @@ export SMARTAPI_Q="tags.name=biothings"
482
532
  export SMARTAPI_API_SET="biothings_core"
483
533
  export SMARTAPI_EXCLUDE_IDS="exclude_id1,exclude_id2"
484
534
 
535
+ # BioThings facade strategy (see "Large API Sets" above)
536
+ export SMARTAPI_FACADE="auto" # auto | on | off
537
+ export FACADE_THRESHOLD="10" # min BioThings APIs before 'auto' uses the facade
538
+ export FACADE_STRICT="false" # inspect specs; serve APIs with extra endpoints per-API
539
+
485
540
  # Server configuration
486
541
  export SERVER_NAME="My SmartAPI MCP Server"
487
542
  export TRANSPORT="http"
@@ -17,7 +17,9 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
17
17
 
18
18
  - Python 3.10 or higher
19
19
  - Network access to SmartAPI registry (https://smart-api.info)
20
- - Dependencies: `awslabs_openapi_mcp_server>=0.2.12`
20
+ - Dependencies: `awslabs_openapi_mcp_server>=0.2.12,<1` and `fastmcp>=2.14,<3`
21
+ (awslabs 1.x / fastmcp 3.x are not yet supported — see the dependency notes in
22
+ `pyproject.toml`)
21
23
 
22
24
  ## Features
23
25
 
@@ -27,9 +29,10 @@ Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/ope
27
29
  - 📖 **OpenAPI Validation**: Automatic OpenAPI specification parsing and validation
28
30
  - 🛠️ **CLI Interface**: Easy-to-use command-line interface with multiple configuration options
29
31
  - 🧬 **Bioinformatics Focus**: Pre-configured API sets for bioinformatics and life sciences
32
+ - 🧩 **Scales to large API sets**: Large BioThings sets (e.g. `biothings_all`, 200+ operations) automatically collapse to ~5 generic tools, avoiding client context overflow without relying on client-side tool deferral
30
33
  - 🎯 **Flexible Configuration**: Support for environment variables, arguments, and configuration files
31
34
  - 🚀 **Multiple Transport Modes**: Support for both stdio and HTTP transport protocols
32
- - 🧪 **Comprehensive Testing**: Full test suite with 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.1"
8
8
  description = "Create MCP servers for one or multiple APIs registered in SmartAPI registry"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  "Programming Language :: Python :: 3.11",
25
25
  "Programming Language :: Python :: 3.12",
26
26
  "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
27
28
  "Topic :: Software Development :: Libraries :: Python Modules",
28
29
  "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
29
30
  "Topic :: Scientific/Engineering :: Bio-Informatics",
@@ -31,7 +32,12 @@ classifiers = [
31
32
  keywords = ["mcp", "smartapi", "api", "server", "bioinformatics"]
32
33
  requires-python = ">=3.10"
33
34
  dependencies = [
34
- "awslabs_openapi_mcp_server>=0.2.12",
35
+ # awslabs 1.x requires fastmcp 3.x, which removed FastMCP.get_tools() and
36
+ # rejects *args tool functions -- incompatible with this package for now.
37
+ # fastmcp is also pinned <3 directly because awslabs 0.2.x allows fastmcp>=2.14
38
+ # with no upper bound and would otherwise resolve to the breaking 3.x.
39
+ "awslabs_openapi_mcp_server>=0.2.12,<1",
40
+ "fastmcp>=2.14,<3",
35
41
  ]
36
42
 
37
43
  [project.optional-dependencies]
@@ -4,28 +4,39 @@ SmartAPI MCP Server Package
4
4
  Create MCP servers for one or multiple APIs registered in SmartAPI registry.
5
5
  """
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.3.1"
8
8
  __author__ = "BioThings Team"
9
9
  __email__ = "help@biothings.io"
10
10
 
11
11
  # Optional imports for when dependencies are available
12
12
  try:
13
- from .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