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.
- scripts/compute_tool_fingerprints.py +79 -0
- scripts/security_sanitizer.py +318 -0
- tests/conftest.py +34 -0
- tests/test_adapter_conformance.py +164 -0
- tests/test_api_fdc.py +29 -0
- tests/test_api_gbif.py +45 -0
- tests/test_api_inaturalist.py +28 -0
- tests/test_api_ncbi_taxonomy.py +47 -0
- tests/test_api_noaa.py +37 -0
- tests/test_api_ols4.py +25 -0
- tests/test_api_open_meteo.py +48 -0
- tests/test_api_wikidata.py +41 -0
- tests/test_concept_parity.py +87 -0
- tests/test_init_dynamics.py +66 -0
- tests/test_presets_and_adapters.py +67 -0
- tests/test_startup.py +28 -0
- world_reference_mcp/__init__.py +19 -0
- world_reference_mcp/__main__.py +8 -0
- world_reference_mcp/_version.py +5 -0
- world_reference_mcp/api/__init__.py +11 -0
- world_reference_mcp/api/fdc.py +68 -0
- world_reference_mcp/api/gbif.py +84 -0
- world_reference_mcp/api/inaturalist.py +61 -0
- world_reference_mcp/api/ncbi_taxonomy.py +74 -0
- world_reference_mcp/api/noaa.py +71 -0
- world_reference_mcp/api/ols4.py +52 -0
- world_reference_mcp/api/open_meteo.py +83 -0
- world_reference_mcp/api/wikidata.py +64 -0
- world_reference_mcp/config.py +126 -0
- world_reference_mcp/connectors/__init__.py +14 -0
- world_reference_mcp/connectors/adapters.py +81 -0
- world_reference_mcp/connectors/mcp_source_presets.json +312 -0
- world_reference_mcp/connectors/tool_schema_fingerprints.json +14 -0
- world_reference_mcp/credentials.py +58 -0
- world_reference_mcp/errors.py +13 -0
- world_reference_mcp/http_clients.py +50 -0
- world_reference_mcp/mcp/__init__.py +8 -0
- world_reference_mcp/mcp/clients.py +77 -0
- world_reference_mcp/mcp/server.py +60 -0
- world_reference_mcp/mcp/tools_nutrition.py +35 -0
- world_reference_mcp/mcp/tools_observations.py +86 -0
- world_reference_mcp/mcp/tools_taxonomy.py +40 -0
- world_reference_mcp/mcp/tools_terms.py +31 -0
- world_reference_mcp/mcp/tools_wikidata.py +35 -0
- world_reference_mcp/mcp_config.json +3 -0
- world_reference_mcp/mcp_server.py +26 -0
- world_reference_mcp/ontology/__init__.py +13 -0
- world_reference_mcp/ontology/certification.json +36 -0
- world_reference_mcp/ontology/fixtures/records.json +226 -0
- world_reference_mcp/ontology/mappings/source.yaml +196 -0
- world_reference_mcp/ontology/migrations/manifest.json +6 -0
- world_reference_mcp/ontology/shapes/connector.shacl.ttl +24 -0
- world_reference_mcp/ontology/world_reference.ttl +42 -0
- world_reference_mcp/prompts/__init__.py +0 -0
- world_reference_mcp/prompts/main_agent.json +20 -0
- world_reference_mcp/prompts/world_reference_specialist.json +60 -0
- world_reference_mcp/skills/__init__.py +0 -0
- world_reference_mcp/skills/world-reference-alignment/SKILL.md +80 -0
- world_reference_mcp/skills/world-reference-alignment/WORKFLOW.md +63 -0
- world_reference_mcp/skills/world-reference-mcp-operations/SKILL.md +37 -0
- world_reference_mcp/skills/world-reference-mcp-operations/agents/openai.yaml +4 -0
- world_reference_mcp/skills/world-reference-mcp-operations/references/catalog.md +9 -0
- world_reference_mcp/skills/world-reference-nutrition/SKILL.md +71 -0
- world_reference_mcp/skills/world-reference-nutrition/WORKFLOW.md +56 -0
- world_reference_mcp/skills/world-reference-observations/SKILL.md +100 -0
- world_reference_mcp/skills/world-reference-observations/WORKFLOW.md +82 -0
- world_reference_mcp/skills/world-reference-taxonomy/SKILL.md +93 -0
- world_reference_mcp/skills/world-reference-taxonomy/WORKFLOW.md +76 -0
- world_reference_mcp/skills/world-reference-terms/SKILL.md +79 -0
- world_reference_mcp/skills/world-reference-terms/WORKFLOW.md +63 -0
- world_reference_mcp-0.2.0.dist-info/METADATA +57 -0
- world_reference_mcp-0.2.0.dist-info/RECORD +76 -0
- world_reference_mcp-0.2.0.dist-info/WHEEL +5 -0
- world_reference_mcp-0.2.0.dist-info/entry_points.txt +14 -0
- world_reference_mcp-0.2.0.dist-info/licenses/LICENSE +20 -0
- 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)
|