world-reference-mcp 0.2.0__py3-none-any.whl

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.
Files changed (76) hide show
  1. scripts/compute_tool_fingerprints.py +79 -0
  2. scripts/security_sanitizer.py +318 -0
  3. tests/conftest.py +34 -0
  4. tests/test_adapter_conformance.py +164 -0
  5. tests/test_api_fdc.py +29 -0
  6. tests/test_api_gbif.py +45 -0
  7. tests/test_api_inaturalist.py +28 -0
  8. tests/test_api_ncbi_taxonomy.py +47 -0
  9. tests/test_api_noaa.py +37 -0
  10. tests/test_api_ols4.py +25 -0
  11. tests/test_api_open_meteo.py +48 -0
  12. tests/test_api_wikidata.py +41 -0
  13. tests/test_concept_parity.py +87 -0
  14. tests/test_init_dynamics.py +66 -0
  15. tests/test_presets_and_adapters.py +67 -0
  16. tests/test_startup.py +28 -0
  17. world_reference_mcp/__init__.py +19 -0
  18. world_reference_mcp/__main__.py +8 -0
  19. world_reference_mcp/_version.py +5 -0
  20. world_reference_mcp/api/__init__.py +11 -0
  21. world_reference_mcp/api/fdc.py +68 -0
  22. world_reference_mcp/api/gbif.py +84 -0
  23. world_reference_mcp/api/inaturalist.py +61 -0
  24. world_reference_mcp/api/ncbi_taxonomy.py +74 -0
  25. world_reference_mcp/api/noaa.py +71 -0
  26. world_reference_mcp/api/ols4.py +52 -0
  27. world_reference_mcp/api/open_meteo.py +83 -0
  28. world_reference_mcp/api/wikidata.py +64 -0
  29. world_reference_mcp/config.py +126 -0
  30. world_reference_mcp/connectors/__init__.py +14 -0
  31. world_reference_mcp/connectors/adapters.py +81 -0
  32. world_reference_mcp/connectors/mcp_source_presets.json +312 -0
  33. world_reference_mcp/connectors/tool_schema_fingerprints.json +14 -0
  34. world_reference_mcp/credentials.py +58 -0
  35. world_reference_mcp/errors.py +13 -0
  36. world_reference_mcp/http_clients.py +50 -0
  37. world_reference_mcp/mcp/__init__.py +8 -0
  38. world_reference_mcp/mcp/clients.py +77 -0
  39. world_reference_mcp/mcp/server.py +60 -0
  40. world_reference_mcp/mcp/tools_nutrition.py +35 -0
  41. world_reference_mcp/mcp/tools_observations.py +86 -0
  42. world_reference_mcp/mcp/tools_taxonomy.py +40 -0
  43. world_reference_mcp/mcp/tools_terms.py +31 -0
  44. world_reference_mcp/mcp/tools_wikidata.py +35 -0
  45. world_reference_mcp/mcp_config.json +3 -0
  46. world_reference_mcp/mcp_server.py +26 -0
  47. world_reference_mcp/ontology/__init__.py +13 -0
  48. world_reference_mcp/ontology/certification.json +36 -0
  49. world_reference_mcp/ontology/fixtures/records.json +226 -0
  50. world_reference_mcp/ontology/mappings/source.yaml +196 -0
  51. world_reference_mcp/ontology/migrations/manifest.json +6 -0
  52. world_reference_mcp/ontology/shapes/connector.shacl.ttl +24 -0
  53. world_reference_mcp/ontology/world_reference.ttl +42 -0
  54. world_reference_mcp/prompts/__init__.py +0 -0
  55. world_reference_mcp/prompts/main_agent.json +20 -0
  56. world_reference_mcp/prompts/world_reference_specialist.json +60 -0
  57. world_reference_mcp/skills/__init__.py +0 -0
  58. world_reference_mcp/skills/world-reference-alignment/SKILL.md +80 -0
  59. world_reference_mcp/skills/world-reference-alignment/WORKFLOW.md +63 -0
  60. world_reference_mcp/skills/world-reference-mcp-operations/SKILL.md +37 -0
  61. world_reference_mcp/skills/world-reference-mcp-operations/agents/openai.yaml +4 -0
  62. world_reference_mcp/skills/world-reference-mcp-operations/references/catalog.md +9 -0
  63. world_reference_mcp/skills/world-reference-nutrition/SKILL.md +71 -0
  64. world_reference_mcp/skills/world-reference-nutrition/WORKFLOW.md +56 -0
  65. world_reference_mcp/skills/world-reference-observations/SKILL.md +100 -0
  66. world_reference_mcp/skills/world-reference-observations/WORKFLOW.md +82 -0
  67. world_reference_mcp/skills/world-reference-taxonomy/SKILL.md +93 -0
  68. world_reference_mcp/skills/world-reference-taxonomy/WORKFLOW.md +76 -0
  69. world_reference_mcp/skills/world-reference-terms/SKILL.md +79 -0
  70. world_reference_mcp/skills/world-reference-terms/WORKFLOW.md +63 -0
  71. world_reference_mcp-0.2.0.dist-info/METADATA +57 -0
  72. world_reference_mcp-0.2.0.dist-info/RECORD +76 -0
  73. world_reference_mcp-0.2.0.dist-info/WHEEL +5 -0
  74. world_reference_mcp-0.2.0.dist-info/entry_points.txt +14 -0
  75. world_reference_mcp-0.2.0.dist-info/licenses/LICENSE +20 -0
  76. world_reference_mcp-0.2.0.dist-info/top_level.txt +7 -0
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """Regenerate ``connectors/tool_schema_fingerprints.json`` from the live tool surface.
3
+
4
+ Connects an in-process FastMCP client to a plain server carrying this
5
+ package's nine tools (no auth/network/fleet-registration wiring — those are
6
+ concerns of ``agent_connector_sdk.mcp.server.create_mcp_server``, irrelevant
7
+ to a tool's client-visible schema) and fingerprints exactly what a real
8
+ client's ``tools/list`` would see, via
9
+ ``agent_connector_sdk.certify.fingerprints.tool_fingerprint`` — the same
10
+ function ``discover()`` verifies against at extraction time.
11
+
12
+ Run after changing any tool's signature or docstring-derived schema:
13
+
14
+ uv run --frozen python scripts/compute_tool_fingerprints.py --apply
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import json
22
+ from pathlib import Path
23
+
24
+ from agent_connector_sdk.certify.fingerprints import tool_fingerprint
25
+ from fastmcp import Client, FastMCP
26
+
27
+ from world_reference_mcp.mcp.clients import Clients
28
+ from world_reference_mcp.mcp.tools_nutrition import register_nutrition_tools
29
+ from world_reference_mcp.mcp.tools_observations import register_observation_tools
30
+ from world_reference_mcp.mcp.tools_taxonomy import register_taxonomy_tools
31
+ from world_reference_mcp.mcp.tools_terms import register_term_tools
32
+ from world_reference_mcp.mcp.tools_wikidata import register_wikidata_tools
33
+
34
+ _OUTPUT = Path(__file__).resolve().parent.parent / (
35
+ "world_reference_mcp/connectors/tool_schema_fingerprints.json"
36
+ )
37
+
38
+
39
+ def _bare_server() -> FastMCP:
40
+ mcp: FastMCP = FastMCP("world-reference-mcp")
41
+ clients = Clients()
42
+ register_taxonomy_tools(mcp, clients)
43
+ register_term_tools(mcp, clients)
44
+ register_nutrition_tools(mcp, clients)
45
+ register_observation_tools(mcp, clients)
46
+ register_wikidata_tools(mcp, clients)
47
+ return mcp
48
+
49
+
50
+ async def _fingerprints() -> dict[str, str]:
51
+ mcp = _bare_server()
52
+ async with Client(mcp) as client:
53
+ tools = await client.list_tools()
54
+ return {tool.name: tool_fingerprint(tool) for tool in tools}
55
+
56
+
57
+ def main() -> int:
58
+ parser = argparse.ArgumentParser(description=__doc__)
59
+ parser.add_argument("--apply", action="store_true", help="write the output file")
60
+ args = parser.parse_args()
61
+ fingerprints = asyncio.run(_fingerprints())
62
+ document = {
63
+ "_comment": (
64
+ "Generated by scripts/compute_tool_fingerprints.py from the live "
65
+ "tools/list schema. Never hand-edit."
66
+ ),
67
+ "tools": dict(sorted(fingerprints.items())),
68
+ }
69
+ text = json.dumps(document, indent=2, sort_keys=False) + "\n"
70
+ if args.apply:
71
+ _OUTPUT.write_text(text)
72
+ print(f"wrote {_OUTPUT}")
73
+ else:
74
+ print(text)
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ MAX_SCAN_BYTES = 8 * 1024 * 1024
9
+
10
+ # Config
11
+ # llms.txt is the deliberate root-level AI entry index (llms-txt convention,
12
+ # like robots.txt) shipped by the docs/day-0 work — not garbage.
13
+ # overrides.txt is the sanctioned uv dependency-override file (UV_OVERRIDE in
14
+ # docker/Dockerfile, mirroring [tool.uv] override-dependencies) shipped by the
15
+ # pydantic-ai v2 migration — a canonical packaging input, not scratch.
16
+ # .security-audit-allow.txt is the OSV dependency-audit risk-acceptance ledger
17
+ # (scripts/audit_dependencies.py, wired into the dependency-audit pre-commit hook)
18
+ # — a committed, actively-read governance input, not scratch/garbage.
19
+ ALLOWED_TXT_NAMES = {
20
+ "requirements.txt",
21
+ "requirements-dev.txt",
22
+ "llms.txt",
23
+ "overrides.txt",
24
+ ".security-audit-allow.txt",
25
+ }
26
+ TRANSIENT_PY_PATTERNS = [
27
+ re.compile(r"^test_.*\.py$"),
28
+ re.compile(r"^fix_.*\.py$"),
29
+ re.compile(r"^debug_.*\.py$"),
30
+ re.compile(r"^scratch_.*\.py$"),
31
+ re.compile(r"^temp_.*\.py$"),
32
+ ]
33
+
34
+ SECRET_PATTERNS = [
35
+ ("GitHub PAT", re.compile(r"ghp_[A-Za-z0-9_]{36,255}")),
36
+ ("GitHub Fine-grained PAT", re.compile(r"github_pat_[A-Za-z0-9_]{82,255}")),
37
+ ("GitLab PAT", re.compile(r"glpat-[A-Za-z0-9\-]{20,255}")),
38
+ ("Private key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")),
39
+ ("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
40
+ ("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b")),
41
+ ("Langfuse secret", re.compile(r"\bsk-lf-[A-Za-z0-9_-]{16,}\b")),
42
+ (
43
+ "Generic Secret Assignment",
44
+ re.compile(
45
+ r"secret[A-Za-z0-9_]*\s*[:=]\s*['\"][A-Za-z0-9_\-\.\~\*]{16,255}['\"]",
46
+ re.IGNORECASE,
47
+ ),
48
+ ),
49
+ (
50
+ "Generic Token Assignment",
51
+ re.compile(
52
+ r"token\s*[:=]\s*['\"][A-Za-z0-9_\-\.\~\*]{16,255}['\"]", re.IGNORECASE
53
+ ),
54
+ ),
55
+ ]
56
+
57
+ EXCLUDED_DIRS = {
58
+ ".git",
59
+ ".venv",
60
+ "venv",
61
+ "node_modules",
62
+ "build",
63
+ "dist",
64
+ "__pycache__",
65
+ ".tox",
66
+ ".specify",
67
+ ".mypy_cache",
68
+ ".pytest_cache",
69
+ ".ruff_cache",
70
+ ".cache",
71
+ }
72
+ EXCLUDED_EXTENSIONS = {
73
+ ".png",
74
+ ".jpg",
75
+ ".jpeg",
76
+ ".gif",
77
+ ".webp",
78
+ ".ico",
79
+ ".pyc",
80
+ ".db",
81
+ ".kuzu",
82
+ ".sqlite",
83
+ ".sqlite3",
84
+ ".zip",
85
+ ".tar.gz",
86
+ ".tgz",
87
+ ".bz2",
88
+ ".xz",
89
+ ".pdf",
90
+ ".bin",
91
+ ".exe",
92
+ ".dll",
93
+ ".so",
94
+ ".dylib",
95
+ ".woff",
96
+ ".woff2",
97
+ ".eot",
98
+ ".ttf",
99
+ ".mp4",
100
+ ".mp3",
101
+ ".wav",
102
+ ".lock",
103
+ ".svg",
104
+ }
105
+
106
+ # Placeholder / Mock indicators
107
+ PLACEHOLDER_VALUES = {
108
+ "1234567890",
109
+ "abcdef12345",
110
+ "abc123youandme",
111
+ "askdfalskdvjas",
112
+ "test_token",
113
+ "test_secret",
114
+ "glpat-askdfalskdvjas",
115
+ "github_pat_12345",
116
+ "glpat-abc123youandme",
117
+ "github_pat_...",
118
+ "glpat-*************",
119
+ "ghp_*************",
120
+ "github_pat_*************",
121
+ "token_*************",
122
+ "secret_*************",
123
+ "glpat-abc",
124
+ "ghp_abc",
125
+ "github_pat_abc",
126
+ "${env:",
127
+ }
128
+ PLACEHOLDER_PREFIXES = ("your_", "your-", "dummy", "example", "mock")
129
+
130
+
131
+ def is_placeholder(match_str: str) -> bool:
132
+ # Generic assignment patterns include the variable name. Judge only the
133
+ # quoted value so names such as ``secret_example`` cannot suppress a real
134
+ # credential finding.
135
+ assignment = re.search(r"['\"]([^'\"]+)['\"]\s*$", match_str)
136
+ candidate = assignment.group(1) if assignment else match_str
137
+ candidate_lower = candidate.strip().lower()
138
+ if candidate_lower in PLACEHOLDER_VALUES or candidate_lower.startswith(
139
+ PLACEHOLDER_PREFIXES
140
+ ):
141
+ return True
142
+
143
+ # Check if match is mostly asterisks or single repeated char
144
+ cleaned = candidate.replace("'", "").replace('"', "").strip()
145
+ if not cleaned:
146
+ return True
147
+
148
+ # Check if there are sequences of asterisks indicating masked values
149
+ if "*" in cleaned:
150
+ # e.g., glpat-*************
151
+ return True
152
+
153
+ compact = re.sub(r"[^A-Za-z0-9]", "", cleaned).lower()
154
+ if len(compact) >= 8 and len(set(compact)) == 1:
155
+ return True
156
+
157
+ return False
158
+
159
+
160
+ def _is_excluded_relative_path(relative: Path) -> bool:
161
+ if relative.is_absolute() or ".." in relative.parts:
162
+ raise ValueError("unsafe source inventory path")
163
+ return any(part in EXCLUDED_DIRS for part in relative.parts)
164
+
165
+
166
+ def _git_tracked_files(repo_path: Path):
167
+ result = subprocess.run(
168
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
169
+ cwd=str(repo_path),
170
+ capture_output=True,
171
+ text=True,
172
+ check=True,
173
+ timeout=30,
174
+ )
175
+ files = []
176
+ for line in result.stdout.splitlines():
177
+ if not line.strip():
178
+ continue
179
+ relative = Path(line.strip())
180
+ # Avoid files inside excluded directories
181
+ if not _is_excluded_relative_path(relative):
182
+ files.append(repo_path / relative)
183
+ return files
184
+
185
+
186
+ def _walk_tracked_files(repo_path: Path):
187
+ # Fallback to manual recursive scan
188
+ files = []
189
+ for root, dirs, walk_files in os.walk(str(repo_path)):
190
+ # Hidden source/config directories (for example ``.github``) can
191
+ # contain credentials and must not disappear merely because Git
192
+ # inventory was unavailable. Exclude only known generated trees.
193
+ dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
194
+ for file in walk_files:
195
+ files.append(Path(root) / file)
196
+ return files
197
+
198
+
199
+ def get_repo_files(repo_path: Path):
200
+ try:
201
+ return _git_tracked_files(repo_path)
202
+ except (OSError, subprocess.SubprocessError, ValueError):
203
+ return _walk_tracked_files(repo_path)
204
+
205
+
206
+ def _root_naming_violations(file_path: Path):
207
+ violations = []
208
+ if file_path.suffix == ".txt":
209
+ if file_path.name.lower() not in ALLOWED_TXT_NAMES:
210
+ violations.append(
211
+ "Non-standard root-level text file detected: "
212
+ f"'{file_path.name}'. Only {sorted(ALLOWED_TXT_NAMES)} "
213
+ "are allowed."
214
+ )
215
+ elif file_path.suffix == ".py":
216
+ for pattern in TRANSIENT_PY_PATTERNS:
217
+ if pattern.match(file_path.name):
218
+ violations.append(
219
+ "Transient/temporary script detected in root: "
220
+ f"'{file_path.name}'. Please move it to a subfolder "
221
+ "or delete it."
222
+ )
223
+ break
224
+ return violations
225
+
226
+
227
+ def _should_secret_scan(file_path: Path) -> bool:
228
+ if file_path.suffix.lower() in EXCLUDED_EXTENSIONS:
229
+ return False
230
+ if file_path.name == "security_sanitizer.py":
231
+ return False
232
+ return True
233
+
234
+
235
+ def _secret_violations_in_line(line: str, rel_path: Path, idx: int):
236
+ violations = []
237
+ for label, pattern in SECRET_PATTERNS:
238
+ for match in pattern.findall(line):
239
+ match_str = match[0] if isinstance(match, tuple) else match
240
+ if not is_placeholder(match_str):
241
+ violations.append(
242
+ f"Potential unmasked secret ({label}) detected in "
243
+ f"{rel_path}:{idx}"
244
+ )
245
+ return violations
246
+
247
+
248
+ def _secret_scan_violations(file_path: Path, repo_path: Path):
249
+ rel_path = file_path.relative_to(repo_path)
250
+ if file_path.stat().st_size > MAX_SCAN_BYTES:
251
+ return [
252
+ f"Source file exceeds security scan boundary: '{rel_path}'",
253
+ ]
254
+
255
+ # Decode strictly. Silently discarding invalid bytes can splice a
256
+ # credential around the discarded byte and make a fail-open scan.
257
+ content = file_path.read_text(encoding="utf-8")
258
+ violations = []
259
+ for idx, line in enumerate(content.splitlines(), 1):
260
+ violations.extend(_secret_violations_in_line(line, rel_path, idx))
261
+ return violations
262
+
263
+
264
+ def _scan_one_file(file_path: Path, repo_path: Path):
265
+ violations = []
266
+
267
+ # 1. Check root level naming constraints
268
+ if file_path.parent == repo_path:
269
+ violations.extend(_root_naming_violations(file_path))
270
+
271
+ # 2. Check for secrets
272
+ if not _should_secret_scan(file_path):
273
+ return violations
274
+
275
+ try:
276
+ violations.extend(_secret_scan_violations(file_path, repo_path))
277
+ except (OSError, UnicodeError):
278
+ violations.append(
279
+ f"Source file could not be inspected: "
280
+ f"'{file_path.relative_to(repo_path)}'"
281
+ )
282
+ return violations
283
+
284
+
285
+ def scan_repository(repo_path: Path):
286
+ violations = []
287
+
288
+ for file_path in get_repo_files(repo_path):
289
+ if not file_path.is_file():
290
+ continue
291
+ if file_path.is_symlink():
292
+ # Git tracks the link target text, not the target contents. Never
293
+ # follow a repository symlink into machine-local material.
294
+ continue
295
+ violations.extend(_scan_one_file(file_path, repo_path))
296
+
297
+ return violations
298
+
299
+
300
+ def main():
301
+ repo_path = Path.cwd()
302
+
303
+ print("🔒 Running Security and Garbage Sanitizer...")
304
+ violations = scan_repository(repo_path)
305
+
306
+ if violations:
307
+ print("\n❌ SECURITY AND GARBAGE VALIDATION FAILED!")
308
+ print("Please correct the following issues before committing:")
309
+ for idx, violation in enumerate(violations, 1):
310
+ print(f"\n[{idx}] {violation}")
311
+ sys.exit(1)
312
+
313
+ print("✅ All checks passed! No root garbage or unmasked secrets detected.")
314
+ sys.exit(0)
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
tests/conftest.py ADDED
@@ -0,0 +1,34 @@
1
+ """Shared test fixtures. No live network: every HTTP call is mocked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ FIXTURES = Path(__file__).parent / "fixtures"
13
+
14
+
15
+ def load_fixture(name: str) -> dict:
16
+ """Read one recorded or synthetic fixture JSON file by name."""
17
+ return json.loads((FIXTURES / name).read_text())
18
+
19
+
20
+ def mock_client(
21
+ base_url: str, handler: Callable[[httpx.Request], httpx.Response]
22
+ ) -> httpx.AsyncClient:
23
+ """An ``httpx.AsyncClient`` whose transport is a synchronous fixture handler."""
24
+ return httpx.AsyncClient(base_url=base_url, transport=httpx.MockTransport(handler))
25
+
26
+
27
+ def json_response(payload: dict) -> httpx.Response:
28
+ """A 200 JSON response carrying ``payload``."""
29
+ return httpx.Response(200, json=payload)
30
+
31
+
32
+ @pytest.fixture
33
+ def fixture() -> Callable[[str], dict]:
34
+ return load_fixture
@@ -0,0 +1,164 @@
1
+ """End-to-end ``SourceAdapter`` conformance for a real in-process MCP session.
2
+
3
+ Exercises the full path an EG sync run takes — ``McpToolSourceAdapter`` ->
4
+ ``session.call_tool`` -> the real FastMCP tool -> the real API-layer
5
+ function (monkeypatched here to a small deterministic fixture instead of a
6
+ live NCBI call) -> back through the generic adapter's pagination, checkpoint
7
+ and provenance machinery — using the SDK's own conformance-check functions
8
+ (:mod:`agent_connector_sdk.testing.source_adapters`), the same functions a
9
+ sync run's own tests would use.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import AsyncIterator
15
+ from contextlib import asynccontextmanager
16
+ from typing import Any
17
+
18
+ import pytest
19
+ from agent_connector_sdk.adapters.mcp_tool import McpToolSourceAdapter
20
+ from agent_connector_sdk.manifest.live_contract import validate_preset_tool_contract
21
+ from agent_connector_sdk.manifest.model import SchemaMapping
22
+ from agent_connector_sdk.manifest.presets import ToolPreset
23
+ from agent_connector_sdk.ports.session import TransportEndpoint
24
+ from agent_connector_sdk.testing.results import SessionFactory
25
+ from agent_connector_sdk.testing.source_adapters import (
26
+ check_capability_descriptor,
27
+ check_checkpoint_resume,
28
+ check_idempotent_rerun,
29
+ check_malformed_input_rejection,
30
+ check_pagination,
31
+ check_provenance_completeness,
32
+ sweep,
33
+ )
34
+ from agent_connector_sdk.transports.mcp import McpTransport
35
+ from fastmcp import FastMCP
36
+
37
+ from world_reference_mcp.api import ncbi_taxonomy
38
+ from world_reference_mcp.mcp.clients import Clients
39
+ from world_reference_mcp.mcp.tools_taxonomy import register_taxonomy_tools
40
+
41
+ _TAXA = [
42
+ {
43
+ "ncbi_taxon_id": str(9620 + index),
44
+ "scientific_name": f"Vulpes species-{index}",
45
+ "common_name": "",
46
+ "rank": "species",
47
+ "division": "carnivores",
48
+ "modification_date": "2021/03/24 00:00",
49
+ }
50
+ for index in range(5)
51
+ ]
52
+ _SMALL_PAGE_SIZE = 2
53
+
54
+
55
+ def _small_preset() -> ToolPreset:
56
+ return ToolPreset.from_mapping(
57
+ "taxon_ncbi_small",
58
+ {
59
+ "server": "world-reference-mcp",
60
+ "tool": "taxonomy_ncbi_search",
61
+ "records_path": "items",
62
+ "id_field": "ncbi_taxon_id",
63
+ "updated_field": "modification_date",
64
+ "doc_type": "taxon",
65
+ "pagination": "offset",
66
+ "page_param": "retstart",
67
+ "page_size_param": "retmax",
68
+ "page_size": _SMALL_PAGE_SIZE,
69
+ "arguments": {"term": "Mammalia[Subtree]"},
70
+ "params_style": "args",
71
+ },
72
+ )
73
+
74
+
75
+ def _bare_server() -> FastMCP:
76
+ mcp: FastMCP = FastMCP("world-reference-mcp-test")
77
+ register_taxonomy_tools(mcp, Clients())
78
+ return mcp
79
+
80
+
81
+ def _session_factory(mcp: FastMCP) -> SessionFactory:
82
+ @asynccontextmanager
83
+ async def _open() -> AsyncIterator[Any]:
84
+ async with McpTransport().session(TransportEndpoint(in_process=mcp)) as session:
85
+ yield session
86
+
87
+ return _open
88
+
89
+
90
+ async def _verified_adapter(sessions: SessionFactory) -> McpToolSourceAdapter:
91
+ """Build an adapter pinned to the tool's CURRENT live fingerprint.
92
+
93
+ Mirrors what a real deployment does once, at certification time: read the
94
+ live schema, pin it, then verify every extraction against that pin.
95
+ """
96
+ preset = _small_preset()
97
+ async with sessions() as probe_session:
98
+ contract = await probe_session.list_tools()
99
+ live = validate_preset_tool_contract(
100
+ contract, tool_name=preset.tool, presets=(preset,), expected_schema_sha256=""
101
+ )
102
+ return McpToolSourceAdapter(
103
+ preset,
104
+ connector="world-reference-mcp",
105
+ tool_schema_sha256=live.compatibility_sha256,
106
+ mapping_reference="manifest:world-reference-mcp#schema_mappings/taxon_ncbi_small",
107
+ schema_mappings={"taxon_ncbi_small": SchemaMapping(ontology_class="Taxon")},
108
+ )
109
+
110
+
111
+ async def _good_page(
112
+ client: Any, *, term: str, retstart: int, retmax: int
113
+ ) -> dict[str, Any]:
114
+ return {
115
+ "items": _TAXA[retstart : retstart + retmax],
116
+ "offset": retstart,
117
+ "limit": retmax,
118
+ "total": len(_TAXA),
119
+ }
120
+
121
+
122
+ async def _malformed_page(
123
+ client: Any, *, term: str, retstart: int, retmax: int
124
+ ) -> dict[str, Any]:
125
+ return {
126
+ "items": [{"scientific_name": "no id field"}],
127
+ "offset": 0,
128
+ "limit": 1,
129
+ "total": 1,
130
+ }
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_good_source_conforms(monkeypatch: pytest.MonkeyPatch) -> None:
135
+ monkeypatch.setattr(ncbi_taxonomy, "search_taxa", _good_page)
136
+ sessions = _session_factory(_bare_server())
137
+ adapter = await _verified_adapter(sessions)
138
+
139
+ results = [
140
+ check_capability_descriptor(adapter),
141
+ await check_pagination(adapter, sessions),
142
+ await check_checkpoint_resume(adapter, sessions),
143
+ await check_idempotent_rerun(adapter, sessions),
144
+ ]
145
+ async with sessions() as session:
146
+ records = (await sweep(adapter, session)).records
147
+ results.append(check_provenance_completeness(records))
148
+
149
+ failed = [result for result in results if not result.passed]
150
+ assert not failed, failed
151
+ assert len(records) == len(_TAXA)
152
+ expected_ids = {taxon["ncbi_taxon_id"] for taxon in _TAXA}
153
+ assert {record.record_id for record in records} == expected_ids
154
+
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_malformed_source_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
158
+ monkeypatch.setattr(ncbi_taxonomy, "search_taxa", _good_page)
159
+ sessions = _session_factory(_bare_server())
160
+ adapter = await _verified_adapter(sessions)
161
+
162
+ monkeypatch.setattr(ncbi_taxonomy, "search_taxa", _malformed_page)
163
+ result = await check_malformed_input_rejection(adapter, sessions)
164
+ assert result.passed, result.detail
tests/test_api_fdc.py ADDED
@@ -0,0 +1,29 @@
1
+ """USDA FoodData Central API-layer test against a synthetic fixture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from tests.conftest import json_response, load_fixture, mock_client
9
+ from world_reference_mcp.api.fdc import search_foods
10
+
11
+
12
+ @pytest.mark.asyncio
13
+ async def test_search_foods_maps_nutrients() -> None:
14
+ def handler(request: httpx.Request) -> httpx.Response:
15
+ assert request.url.path == "/fdc/v1/foods/search"
16
+ assert request.url.params["api_key"] == "test-key"
17
+ return json_response(load_fixture("fdc_food_search.json"))
18
+
19
+ async with mock_client("https://api.nal.usda.gov/fdc/v1", handler) as client:
20
+ page = await search_foods(
21
+ client, api_key="test-key", query="fruit", page_number=1, page_size=50
22
+ )
23
+ assert page["total"] == 2
24
+ apple = page["items"][0]
25
+ assert apple["fdc_id"] == "1750340"
26
+ assert apple["description"] == "Apples, raw, with skin"
27
+ energy = next(n for n in apple["nutrients"] if n["nutrient_name"] == "Energy")
28
+ assert energy["amount"] == 52.0
29
+ assert energy["unit_name"] == "KCAL"
tests/test_api_gbif.py ADDED
@@ -0,0 +1,45 @@
1
+ """GBIF API-layer tests against recorded species/occurrence search fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from tests.conftest import json_response, load_fixture, mock_client
9
+ from world_reference_mcp.api.gbif import search_occurrences, search_species
10
+
11
+
12
+ @pytest.mark.asyncio
13
+ async def test_search_species_maps_fields() -> None:
14
+ def handler(request: httpx.Request) -> httpx.Response:
15
+ assert request.url.path == "/v1/species/search"
16
+ assert request.url.params["rank"] == "SPECIES"
17
+ return json_response(load_fixture("gbif_species_search.json"))
18
+
19
+ async with mock_client("https://api.gbif.org/v1", handler) as client:
20
+ page = await search_species(
21
+ client, query="Vulpes vulpes", rank="species", offset=0, limit=2
22
+ )
23
+ assert page["offset"] == 0
24
+ (item, _) = page["items"]
25
+ assert item["gbif_taxon_key"] == "304190692"
26
+ assert item["scientific_name"] == "Vulpes vulpes"
27
+ assert item["rank"] == "SPECIES"
28
+ assert item["kingdom"] == "Animalia"
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_search_occurrences_maps_fields() -> None:
33
+ def handler(request: httpx.Request) -> httpx.Response:
34
+ assert request.url.path == "/v1/occurrence/search"
35
+ return json_response(load_fixture("gbif_occurrence_search.json"))
36
+
37
+ async with mock_client("https://api.gbif.org/v1", handler) as client:
38
+ page = await search_occurrences(
39
+ client, scientific_name="Vulpes vulpes", offset=0, limit=1
40
+ )
41
+ (item,) = page["items"]
42
+ assert item["occurrence_id"] == "5936472487"
43
+ assert item["gbif_taxon_key"] == "5219243"
44
+ assert item["country_code"] == "FI"
45
+ assert item["decimal_latitude"] == pytest.approx(60.319882)