smartapi-mcp 0.3.2__tar.gz → 0.5.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.3.2
3
+ Version: 0.5.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>
@@ -25,13 +25,14 @@ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
25
25
  Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
- Requires-Dist: awslabs_openapi_mcp_server<1,>=0.2.12
29
- Requires-Dist: fastmcp<3,>=2.14
28
+ Requires-Dist: fastmcp<4,>=3.3.1
29
+ Requires-Dist: httpx<1,>=0.28.1
30
+ Requires-Dist: loguru<1,>=0.7.3
30
31
  Provides-Extra: dev
31
32
  Requires-Dist: pytest>=7.0.0; extra == "dev"
32
33
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
33
34
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
34
- Requires-Dist: ruff>=0.1.0; extra == "dev"
35
+ Requires-Dist: ruff<0.17,>=0.14; extra == "dev"
35
36
  Requires-Dist: build>=0.8.0; extra == "dev"
36
37
  Requires-Dist: twine>=4.0.0; extra == "dev"
37
38
  Provides-Extra: test
@@ -56,15 +57,17 @@ Create MCP (Model Context Protocol) servers for one or multiple APIs registered
56
57
 
57
58
  The SmartAPI MCP Server enables integration between MCP-compatible clients and APIs registered in the SmartAPI registry. This allows for seamless discovery and interaction with bioinformatics and life sciences APIs through standardized MCP protocols.
58
59
 
59
- Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/openapi-mcp-server), this project extends MCP support to the extensive collection of APIs available in the SmartAPI registry, with special focus on bioinformatics and life sciences APIs.
60
+ Built directly on [FastMCP](https://github.com/jlowin/fastmcp), this project extends MCP support to the extensive collection of APIs available in the SmartAPI registry, with special focus on bioinformatics and life sciences APIs.
61
+
62
+ Earlier releases (through 0.3.2) wrapped the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/openapi-mcp-server); parts of `smartapi_mcp/openapi.py` are derived from that project's Apache-2.0 code. Dropping that wrapper reduced a clean install from 87 packages / 91 MB to 68 / 56 MB.
60
63
 
61
64
  ## Requirements
62
65
 
63
66
  - Python 3.10 or higher
64
67
  - Network access to SmartAPI registry (https://smart-api.info)
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`)
68
+ - Dependencies: `fastmcp>=3.3.1,<4`, `httpx`, `loguru` (fastmcp 4.x is not yet
69
+ supported — it moves to the MCP 2.x SDK and `httpx2`; see the dependency notes
70
+ in `pyproject.toml`)
68
71
 
69
72
  ## Features
70
73
 
@@ -247,6 +250,41 @@ inspect each BioThings spec at startup and serve any API that has extra
247
250
  endpoints with faithful per-API tools instead — at the cost of slower startup
248
251
  (it downloads the specs upfront).
249
252
 
253
+ #### Tool search (`--tool-search`)
254
+
255
+ The facade solves the tool explosion for BioThings APIs. `--tool-search` solves
256
+ it for everything else, including per-API tools in a hybrid server: instead of
257
+ listing every tool, the server lists two synthetic tools — `search_tools` and
258
+ `call_tool` — and the model discovers what it needs on demand.
259
+
260
+ ```bash
261
+ smartapi-mcp --api_set biothings_all --tool-search bm25
262
+ ```
263
+
264
+ Every tool stays *callable* through `call_tool`; only the listing changes. Facade
265
+ tools (`biothings_query`, `biothings_get`, …) stay listed, so the common path
266
+ remains directly callable and only the per-API long tail is collapsed:
267
+
268
+ ```
269
+ Tool search (bm25) enabled: 13 tools collapsed to 7 listed
270
+ (5 pinned + search_tools/call_tool); max_results=5.
271
+ All 13 tools stay callable via call_tool.
272
+ ```
273
+
274
+ | Mode | Behaviour |
275
+ | --- | --- |
276
+ | `off` (default) | List every tool |
277
+ | `bm25` | Rank matches by keyword relevance |
278
+ | `regex` | Match tool names/descriptions by pattern |
279
+
280
+ `--tool-search-max-results` (default 5) caps the hits per search. Both options
281
+ have environment equivalents: `SMARTAPI_TOOL_SEARCH` and
282
+ `TOOL_SEARCH_MAX_RESULTS`.
283
+
284
+ Note that discovery-on-demand costs the model an extra round trip per unfamiliar
285
+ tool, so leave it `off` for small sets where the full list already fits
286
+ comfortably.
287
+
250
288
  #### Development/Testing Setup
251
289
 
252
290
  ```json
@@ -451,9 +489,10 @@ from smartapi_mcp import (
451
489
  load_api_spec,
452
490
  get_mcp_server,
453
491
  get_merged_mcp_server,
454
- PREDEFINED_API_SETS
492
+ PREDEFINED_API_SETS,
455
493
  )
456
494
 
495
+
457
496
  async def main():
458
497
  # Get SmartAPI IDs using a query
459
498
  smartapi_ids = await get_smartapi_ids("tags.name=biothings")
@@ -463,16 +502,15 @@ async def main():
463
502
  api_spec = load_api_spec("59dce17363dce279d389100834e43648") # MyGene.info
464
503
  print(f"Loaded API: {api_spec.get('info', {}).get('title', 'Unknown')}")
465
504
 
466
- # Create MCP server for a single API
467
- server = await get_mcp_server(
468
- smartapi_id="59dce17363dce279d389100834e43648",
469
- server_name="MyGene MCP Server"
470
- )
505
+ # Create MCP server for a single API. The server is named after the
506
+ # spec's info.title; pass server_name to the merged/`build_server_for_set`
507
+ # entry points below if you want to choose the name yourself.
508
+ server = await get_mcp_server("59dce17363dce279d389100834e43648")
471
509
 
472
510
  # Create merged MCP server for multiple APIs (recommended approach)
473
511
  merged_server = await get_merged_mcp_server(
474
512
  api_set="biothings_core", # Use predefined set
475
- server_name="BioThings Core MCP Server"
513
+ server_name="BioThings Core MCP Server",
476
514
  )
477
515
 
478
516
  # Or with specific SmartAPI IDs
@@ -481,7 +519,7 @@ async def main():
481
519
  "59dce17363dce279d389100834e43648", # MyGene.info
482
520
  "09c8782d9f4027712e65b95424adba79", # MyVariant.info
483
521
  ],
484
- server_name="Custom MCP Server"
522
+ server_name="Custom MCP Server",
485
523
  )
486
524
 
487
525
  # Show available predefined API sets
@@ -493,6 +531,7 @@ async def main():
493
531
  # Or run with HTTP transport
494
532
  # merged_server.run(transport="http", host="localhost", port=8000)
495
533
 
534
+
496
535
  if __name__ == "__main__":
497
536
  asyncio.run(main())
498
537
  ```
@@ -715,7 +754,8 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS
715
754
  - **[SmartAPI Registry](https://smart-api.info/)** - Registry of biomedical and life sciences APIs
716
755
  - **[Model Context Protocol (MCP)](https://modelcontextprotocol.io/)** - Standard protocol for AI model-tool integration
717
756
  - **[BioThings APIs](https://biothings.io/)** - High-performance bioinformatics APIs
718
- - **[AWS Labs OpenAPI MCP Server](https://github.com/awslabs/openapi-mcp-server)** - Base MCP server framework
757
+ - **[FastMCP](https://github.com/jlowin/fastmcp)** - MCP server framework this package builds on
758
+ - **[AWS Labs OpenAPI MCP Server](https://github.com/awslabs/openapi-mcp-server)** - Wrapped by releases through 0.3.2; some code in `smartapi_mcp/openapi.py` is derived from it
719
759
 
720
760
  ## Citation
721
761
 
@@ -11,15 +11,17 @@ Create MCP (Model Context Protocol) servers for one or multiple APIs registered
11
11
 
12
12
  The SmartAPI MCP Server enables integration between MCP-compatible clients and APIs registered in the SmartAPI registry. This allows for seamless discovery and interaction with bioinformatics and life sciences APIs through standardized MCP protocols.
13
13
 
14
- Built on top of the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/openapi-mcp-server), this project extends MCP support to the extensive collection of APIs available in the SmartAPI registry, with special focus on bioinformatics and life sciences APIs.
14
+ Built directly on [FastMCP](https://github.com/jlowin/fastmcp), this project extends MCP support to the extensive collection of APIs available in the SmartAPI registry, with special focus on bioinformatics and life sciences APIs.
15
+
16
+ Earlier releases (through 0.3.2) wrapped the [AWS Labs OpenAPI MCP Server](https://github.com/awslabs/openapi-mcp-server); parts of `smartapi_mcp/openapi.py` are derived from that project's Apache-2.0 code. Dropping that wrapper reduced a clean install from 87 packages / 91 MB to 68 / 56 MB.
15
17
 
16
18
  ## Requirements
17
19
 
18
20
  - Python 3.10 or higher
19
21
  - Network access to SmartAPI registry (https://smart-api.info)
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`)
22
+ - Dependencies: `fastmcp>=3.3.1,<4`, `httpx`, `loguru` (fastmcp 4.x is not yet
23
+ supported — it moves to the MCP 2.x SDK and `httpx2`; see the dependency notes
24
+ in `pyproject.toml`)
23
25
 
24
26
  ## Features
25
27
 
@@ -202,6 +204,41 @@ inspect each BioThings spec at startup and serve any API that has extra
202
204
  endpoints with faithful per-API tools instead — at the cost of slower startup
203
205
  (it downloads the specs upfront).
204
206
 
207
+ #### Tool search (`--tool-search`)
208
+
209
+ The facade solves the tool explosion for BioThings APIs. `--tool-search` solves
210
+ it for everything else, including per-API tools in a hybrid server: instead of
211
+ listing every tool, the server lists two synthetic tools — `search_tools` and
212
+ `call_tool` — and the model discovers what it needs on demand.
213
+
214
+ ```bash
215
+ smartapi-mcp --api_set biothings_all --tool-search bm25
216
+ ```
217
+
218
+ Every tool stays *callable* through `call_tool`; only the listing changes. Facade
219
+ tools (`biothings_query`, `biothings_get`, …) stay listed, so the common path
220
+ remains directly callable and only the per-API long tail is collapsed:
221
+
222
+ ```
223
+ Tool search (bm25) enabled: 13 tools collapsed to 7 listed
224
+ (5 pinned + search_tools/call_tool); max_results=5.
225
+ All 13 tools stay callable via call_tool.
226
+ ```
227
+
228
+ | Mode | Behaviour |
229
+ | --- | --- |
230
+ | `off` (default) | List every tool |
231
+ | `bm25` | Rank matches by keyword relevance |
232
+ | `regex` | Match tool names/descriptions by pattern |
233
+
234
+ `--tool-search-max-results` (default 5) caps the hits per search. Both options
235
+ have environment equivalents: `SMARTAPI_TOOL_SEARCH` and
236
+ `TOOL_SEARCH_MAX_RESULTS`.
237
+
238
+ Note that discovery-on-demand costs the model an extra round trip per unfamiliar
239
+ tool, so leave it `off` for small sets where the full list already fits
240
+ comfortably.
241
+
205
242
  #### Development/Testing Setup
206
243
 
207
244
  ```json
@@ -406,9 +443,10 @@ from smartapi_mcp import (
406
443
  load_api_spec,
407
444
  get_mcp_server,
408
445
  get_merged_mcp_server,
409
- PREDEFINED_API_SETS
446
+ PREDEFINED_API_SETS,
410
447
  )
411
448
 
449
+
412
450
  async def main():
413
451
  # Get SmartAPI IDs using a query
414
452
  smartapi_ids = await get_smartapi_ids("tags.name=biothings")
@@ -418,16 +456,15 @@ async def main():
418
456
  api_spec = load_api_spec("59dce17363dce279d389100834e43648") # MyGene.info
419
457
  print(f"Loaded API: {api_spec.get('info', {}).get('title', 'Unknown')}")
420
458
 
421
- # Create MCP server for a single API
422
- server = await get_mcp_server(
423
- smartapi_id="59dce17363dce279d389100834e43648",
424
- server_name="MyGene MCP Server"
425
- )
459
+ # Create MCP server for a single API. The server is named after the
460
+ # spec's info.title; pass server_name to the merged/`build_server_for_set`
461
+ # entry points below if you want to choose the name yourself.
462
+ server = await get_mcp_server("59dce17363dce279d389100834e43648")
426
463
 
427
464
  # Create merged MCP server for multiple APIs (recommended approach)
428
465
  merged_server = await get_merged_mcp_server(
429
466
  api_set="biothings_core", # Use predefined set
430
- server_name="BioThings Core MCP Server"
467
+ server_name="BioThings Core MCP Server",
431
468
  )
432
469
 
433
470
  # Or with specific SmartAPI IDs
@@ -436,7 +473,7 @@ async def main():
436
473
  "59dce17363dce279d389100834e43648", # MyGene.info
437
474
  "09c8782d9f4027712e65b95424adba79", # MyVariant.info
438
475
  ],
439
- server_name="Custom MCP Server"
476
+ server_name="Custom MCP Server",
440
477
  )
441
478
 
442
479
  # Show available predefined API sets
@@ -448,6 +485,7 @@ async def main():
448
485
  # Or run with HTTP transport
449
486
  # merged_server.run(transport="http", host="localhost", port=8000)
450
487
 
488
+
451
489
  if __name__ == "__main__":
452
490
  asyncio.run(main())
453
491
  ```
@@ -670,7 +708,8 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS
670
708
  - **[SmartAPI Registry](https://smart-api.info/)** - Registry of biomedical and life sciences APIs
671
709
  - **[Model Context Protocol (MCP)](https://modelcontextprotocol.io/)** - Standard protocol for AI model-tool integration
672
710
  - **[BioThings APIs](https://biothings.io/)** - High-performance bioinformatics APIs
673
- - **[AWS Labs OpenAPI MCP Server](https://github.com/awslabs/openapi-mcp-server)** - Base MCP server framework
711
+ - **[FastMCP](https://github.com/jlowin/fastmcp)** - MCP server framework this package builds on
712
+ - **[AWS Labs OpenAPI MCP Server](https://github.com/awslabs/openapi-mcp-server)** - Wrapped by releases through 0.3.2; some code in `smartapi_mcp/openapi.py` is derived from it
674
713
 
675
714
  ## Citation
676
715
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "smartapi-mcp"
7
- version = "0.3.2"
7
+ version = "0.5.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"
@@ -32,12 +32,15 @@ classifiers = [
32
32
  keywords = ["mcp", "smartapi", "api", "server", "bioinformatics"]
33
33
  requires-python = ">=3.10"
34
34
  dependencies = [
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
+ # Upper bound: fastmcp 4.x moves to the MCP 2.x SDK and httpx2, which this
36
+ # package has not been migrated to or tested against.
37
+ "fastmcp>=3.3.1,<4",
38
+ # Spec fetching. Also a fastmcp[client] dependency, but declared here
39
+ # because smartapi_mcp.openapi and smartapi_mcp.smartapi import it directly.
40
+ "httpx>=0.28.1,<1",
41
+ # Logging. Kept after dropping awslabs_openapi_mcp_server, which supplied
42
+ # the preconfigured logger this package's modules were written against.
43
+ "loguru>=0.7.3,<1",
41
44
  ]
42
45
 
43
46
  [project.optional-dependencies]
@@ -45,7 +48,10 @@ dev = [
45
48
  "pytest>=7.0.0",
46
49
  "pytest-asyncio>=0.21.0",
47
50
  "pytest-cov>=4.0.0",
48
- "ruff>=0.1.0",
51
+ # Upper bound deliberately: an unpinned linter makes CI results drift
52
+ # with upstream releases, failing on unchanged code. Raise it
53
+ # intentionally, with a lint/format pass, rather than by surprise.
54
+ "ruff>=0.14,<0.17",
49
55
  "build>=0.8.0",
50
56
  "twine>=4.0.0",
51
57
  ]
@@ -92,7 +98,6 @@ asyncio_mode = "auto"
92
98
  [tool.coverage.run]
93
99
  source = ["smartapi_mcp"]
94
100
  omit = [
95
- "smartapi_mcp/awslabs_server.py",
96
101
  "smartapi_mcp/__main__.py",
97
102
  "tests/*",
98
103
  ]
@@ -199,8 +204,11 @@ ignore = [
199
204
  "B027",
200
205
  # Allow boolean positional values in function calls
201
206
  "FBT003",
202
- # Ignore complexity
203
- "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915",
207
+ # Ignore complexity. PLR0917 (too-many-positional-arguments) is the same
208
+ # complaint as PLR0913 for the build entry points, which take a set of
209
+ # mutually exclusive API selectors; making them keyword-only would be a
210
+ # breaking change to the public API.
211
+ "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLR0917",
204
212
  # Allow print statements (useful for CLI tools)
205
213
  "T201",
206
214
  # Allow assert statements
@@ -246,8 +254,5 @@ max-complexity = 10
246
254
  "tests/*" = ["PLR2004", "S101", "PT011", "PT012", "ARG002", "PT019"]
247
255
  # CLI module can use print statements and sys.exit
248
256
  "smartapi_mcp/cli.py" = ["T201", "PLR0913"]
249
- # Do not fix linting errors in awslabs_server.py as it's
250
- # based on the original codebase
251
- "smartapi_mcp/awslabs_server.py" = ["ALL"]
252
257
  # Allow star imports and unused imports in __init__.py
253
258
  "__init__.py" = ["F401", "F403", "F405"]
@@ -4,13 +4,19 @@ 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.3.2"
7
+ __version__ = "0.5.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
13
  from .biothings import build_biothings_facade, build_registry
14
+ from .openapi import (
15
+ SpecError,
16
+ build_openapi_server,
17
+ fetch_spec,
18
+ resolve_internal_refs,
19
+ )
14
20
  from .server import (
15
21
  build_server_for_set,
16
22
  get_mcp_server,
@@ -28,9 +34,12 @@ try:
28
34
 
29
35
  __all__ = [
30
36
  "PREDEFINED_API_SETS",
37
+ "SpecError",
31
38
  "build_biothings_facade",
39
+ "build_openapi_server",
32
40
  "build_registry",
33
41
  "build_server_for_set",
42
+ "fetch_spec",
34
43
  "get_base_server_url",
35
44
  "get_mcp_server",
36
45
  "get_merged_mcp_server",
@@ -39,6 +48,7 @@ try:
39
48
  "get_smartapi_registry",
40
49
  "load_api_spec",
41
50
  "merge_mcp_servers",
51
+ "resolve_internal_refs",
42
52
  ]
43
53
  except ImportError:
44
54
  # Dependencies not available, only export version info
@@ -12,15 +12,17 @@ the set, so the server works on every MCP client without depending on runtime
12
12
  """
13
13
 
14
14
  import asyncio
15
+ import math
15
16
  import re
16
17
  from dataclasses import dataclass, field
17
18
 
18
19
  import httpx
19
- from awslabs.openapi_mcp_server import logger
20
20
  from fastmcp import FastMCP
21
21
  from fastmcp.tools import Tool
22
22
 
23
+ from .log import logger
23
24
  from .smartapi import (
25
+ CORE_BIOTHINGS_API_IDS,
24
26
  HTTP_TIMEOUT,
25
27
  get_base_server_url,
26
28
  get_smartapi_registry,
@@ -37,6 +39,20 @@ _MAX_DESC_LEN = 200
37
39
  # HTTP methods recognized when enumerating spec operations.
38
40
  _HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
39
41
 
42
+ # Tags that disqualify an API from the BioThings *annotation-API family* even
43
+ # though it carries the "biothings" tag. TRAPI services (BioThings Explorer,
44
+ # Service Provider) are built by the same team and tagged accordingly, but they
45
+ # speak the Translator Reasoner API -- a query-graph protocol -- not the
46
+ # BioThings annotation interface, so none of the generic facade tools apply to
47
+ # them. They are served with faithful per-API tools instead.
48
+ #
49
+ # This is not a cosmetic distinction: the facade infers an entity type from the
50
+ # first ``/{type}/{id}``-shaped path, and BTE's ``GET /asyncquery_status/{id}``
51
+ # matches that shape. Without this exclusion, ``biothings_get`` would request
52
+ # ``/asyncquery_status/<id>`` and return a job status as though it were an
53
+ # annotation record -- a wrong answer with no error.
54
+ NON_FAMILY_TAGS = frozenset({"trapi"})
55
+
40
56
 
41
57
  @dataclass
42
58
  class BioThingsAPIEntry:
@@ -96,11 +112,21 @@ async def build_registry(
96
112
  return build_registry_from_entries(entries)
97
113
 
98
114
 
115
+ def is_biothings_family(entry: BioThingsAPIEntry) -> bool:
116
+ """Whether ``entry`` is a BioThings annotation API the facade can serve.
117
+
118
+ Requires the ``biothings`` tag and the absence of any
119
+ :data:`NON_FAMILY_TAGS`.
120
+ """
121
+ tags = {str(tag).strip().lower() for tag in entry.tags}
122
+ return "biothings" in tags and not (tags & NON_FAMILY_TAGS)
123
+
124
+
99
125
  def is_biothings_registry(registry: dict[str, BioThingsAPIEntry]) -> bool:
100
- """True only if every API in the registry is tagged ``biothings``."""
126
+ """True only if every API in the registry is a facade-servable BioThings API."""
101
127
  if not registry:
102
128
  return False
103
- return all("biothings" in entry.tags for entry in registry.values())
129
+ return all(is_biothings_family(entry) for entry in registry.values())
104
130
 
105
131
 
106
132
  def _resolve_endpoints(entry: BioThingsAPIEntry) -> None:
@@ -225,12 +251,146 @@ async def partition_biothings(
225
251
  return facade_entries, extra_ids
226
252
 
227
253
 
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)
254
+ # Words too common to discriminate between APIs. Without this, a natural-language
255
+ # intent like "get a gene annotation by its Entrez gene id" is dominated by
256
+ # "get"/"a"/"by"/"its"/"id" rather than by "gene" and "entrez".
257
+ _STOPWORDS = frozenset(
258
+ {
259
+ "a",
260
+ "an",
261
+ "and",
262
+ "are",
263
+ "as",
264
+ "at",
265
+ "be",
266
+ "by",
267
+ "for",
268
+ "from",
269
+ "get",
270
+ "how",
271
+ "in",
272
+ "into",
273
+ "is",
274
+ "it",
275
+ "its",
276
+ "of",
277
+ "on",
278
+ "or",
279
+ "that",
280
+ "the",
281
+ "their",
282
+ "them",
283
+ "there",
284
+ "these",
285
+ "this",
286
+ "to",
287
+ "what",
288
+ "when",
289
+ "where",
290
+ "which",
291
+ "who",
292
+ "with",
293
+ "api",
294
+ "apis",
295
+ "data",
296
+ "database",
297
+ "dataset",
298
+ "info",
299
+ "information",
300
+ "record",
301
+ "records",
302
+ "service",
303
+ "services",
304
+ }
305
+ )
306
+
307
+
308
+ def _tokenize(text: str) -> list[str]:
309
+ """Lower-case word tokens. Word-based, not substring-based, on purpose.
310
+
311
+ The previous scorer used ``str.count`` on the raw text, so a query term
312
+ like "id" also matched inside "identifier", "candidate" and "provide".
313
+ """
314
+ return re.findall(r"[a-z0-9]+", text.lower())
315
+
316
+
317
+ # How much more a term in the API's name/title counts than one buried in its
318
+ # description. An API *named* for a concept is a far stronger answer to a query
319
+ # about that concept than one merely mentioning it in prose; without this,
320
+ # scores tie constantly and the tie-break (alphabetical) decides, which
321
+ # systematically favours the "biothings_*"-prefixed names over MyGene/MyChem.
322
+ _NAME_FIELD_WEIGHT = 3.0
323
+
324
+ # Score multiplier for the core BioThings APIs (:data:`CORE_BIOTHINGS_API_IDS`).
325
+ #
326
+ # These are the canonical broad-coverage services, and they are the *worst*
327
+ # served by pure lexical scoring: being general means their descriptions carry
328
+ # the least distinctive vocabulary, while single-source satellite APIs read as
329
+ # highly specific. So a lexical ranker systematically under-ranks exactly the
330
+ # APIs a user most often wants, and needs a prior to correct for it.
331
+ #
332
+ # Measured on 20 BioThings intents. With the registry descriptions as they were
333
+ # before the core-API enrichment, no boost gave recall@5 16/20 (MRR 0.71) and
334
+ # only 3 of the 6 core-API intents were answered; 1.2 gives 19/20 (MRR 0.82).
335
+ # With the enriched descriptions live, no boost already gives 19/20 (MRR 0.92)
336
+ # and 1.2 gives 20/20 (MRR 0.90). Larger values buy nothing on recall and cost
337
+ # ranking quality once the metadata is good -- at 3.0 the post-enrichment MRR
338
+ # falls to 0.81 -- so this is deliberately the smallest value that captures the
339
+ # benefit, and it stays close to neutral as the metadata improves.
340
+ CORE_API_BOOST = 1.2
341
+
342
+ _CORE_API_IDS = frozenset(CORE_BIOTHINGS_API_IDS)
343
+
344
+
345
+ def _entry_terms(entry: BioThingsAPIEntry) -> tuple[set[str], set[str]]:
346
+ """Return ``(name_terms, all_terms)`` for one API.
347
+
348
+ ``name_terms`` covers the short name, title and tags -- the curated labels;
349
+ ``all_terms`` adds the free-text description.
350
+ """
351
+ name_terms = set(
352
+ _tokenize(" ".join([entry.name, entry.title, " ".join(map(str, entry.tags))]))
353
+ )
354
+ return name_terms, name_terms | set(_tokenize(entry.description))
355
+
356
+
357
+ def _score_entry(
358
+ terms: tuple[set[str], set[str]],
359
+ query_terms: list[str],
360
+ idf: dict[str, float],
361
+ ) -> float:
362
+ """Score one API against a query. Higher is more relevant.
363
+
364
+ ``terms`` is the ``(name_terms, all_terms)`` pair from :func:`_entry_terms`.
365
+
366
+ Three deliberate properties:
367
+
368
+ * **Binary term frequency.** A term counts once however often it appears, so
369
+ an API with a long, repetitive description no longer outranks a precisely
370
+ matching one. This was the concrete defect in the previous scorer:
371
+ searching "get a gene annotation by its Entrez gene id" ranked MyGeneSet
372
+ above MyGene, because summed substring counts reward verbosity.
373
+ * **IDF weighting.** A term shared by most APIs ("gene", "translator")
374
+ contributes almost nothing, while a rare one ("entrez", "ngd", "taxonomy")
375
+ dominates -- which is what makes an intent select the API it names.
376
+ * **Field weighting.** A hit in the name/title/tags counts
377
+ :data:`_NAME_FIELD_WEIGHT` times one that is only in the description. An
378
+ API *named* for a concept answers a query about it far better than one
379
+ merely mentioning it in prose, and without this, scores tie constantly and
380
+ the alphabetical tie-break decides -- which systematically favoured the
381
+ ``biothings_*``-prefixed names over MyGene/MyChem/MyVariant.
382
+ """
383
+ name_terms, all_terms = terms
384
+ score = 0.0
385
+ for term in query_terms:
386
+ weight = idf.get(term, 0.0)
387
+ if not weight:
388
+ continue
389
+ if term in name_terms:
390
+ score += weight * _NAME_FIELD_WEIGHT
391
+ elif term in all_terms:
392
+ score += weight
393
+ return score
234
394
 
235
395
 
236
396
  def rank_apis(
@@ -258,11 +418,38 @@ def rank_apis(
258
418
  if not keyword or not keyword.strip():
259
419
  return [_as_dict(registry[name]) for name in sorted(registry)]
260
420
 
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()]
421
+ query_terms = [t for t in _tokenize(keyword) if t not in _STOPWORDS]
422
+ if not query_terms:
423
+ # Nothing discriminating left (e.g. "what data is there?"): fall back to
424
+ # the full catalog rather than returning an arbitrary subset.
425
+ return [_as_dict(registry[name]) for name in sorted(registry)]
426
+
427
+ terms_by_name = {name: _entry_terms(entry) for name, entry in registry.items()}
428
+ total = len(registry)
429
+ idf = {}
430
+ for term in set(query_terms):
431
+ seen_in = sum(1 for _, all_terms in terms_by_name.values() if term in all_terms)
432
+ # Robertson/Sparck-Jones IDF. Chosen over the plain log(N/n) form
433
+ # because it stays strictly positive even for a term present in every
434
+ # API: with a small registry (say two APIs that both mention "gene"),
435
+ # a zero weight would drop every candidate and the search would answer
436
+ # "nothing found" for a query that in fact matches everything.
437
+ idf[term] = math.log(1 + (total - seen_in + 0.5) / (seen_in + 0.5))
438
+
439
+ scored = []
440
+ for name, entry in registry.items():
441
+ score = _score_entry(terms_by_name[name], query_terms, idf)
442
+ if entry.smartapi_id in _CORE_API_IDS:
443
+ # Multiplicative, not additive: a zero score stays zero, so the
444
+ # boost reorders results that already match and never promotes a
445
+ # core API into a query it has nothing to do with.
446
+ score *= CORE_API_BOOST
447
+ scored.append((score, entry))
263
448
  scored = [pair for pair in scored if pair[0] > 0]
264
449
  scored.sort(key=lambda pair: (-pair[0], pair[1].name))
265
- return [{**_as_dict(entry), "score": score} for score, entry in scored[:limit]]
450
+ return [
451
+ {**_as_dict(entry), "score": round(score, 3)} for score, entry in scored[:limit]
452
+ ]
266
453
 
267
454
 
268
455
  def build_biothings_facade(