attestari-mcp 0.1.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.
@@ -0,0 +1,47 @@
1
+ node_modules/
2
+ .next/
3
+ out/
4
+ .vercel/
5
+ .env
6
+ .env.local
7
+ .env.*.local
8
+ *.tsbuildinfo
9
+ next-env.d.ts
10
+ .DS_Store
11
+
12
+ # analysis pipeline
13
+ analysis/.venv/
14
+ analysis/api/.venv/
15
+ analysis/api/.pytest_cache/
16
+ analysis/api/.ruff_cache/
17
+ analysis/evidence/
18
+ analysis/**/__pycache__/
19
+ analysis/.pytest_cache/
20
+ analysis/runs/*/prompts.json
21
+ analysis/.hypothesis/
22
+
23
+ # infra: files rendered from templates by the scripts, applied and removed
24
+ infra/.*.rendered.*
25
+
26
+ analysis/corpus/.scratch/
27
+
28
+ # accounts service
29
+ accounts/.venv/
30
+ accounts/.pytest_cache/
31
+ accounts/.ruff_cache/
32
+ accounts/**/__pycache__/
33
+
34
+ # the MCP server (mcp/, docs/app.attestari.ai/phase-1-mcp-plan.md)
35
+ mcp/.venv/
36
+ mcp/.pytest_cache/
37
+ mcp/.ruff_cache/
38
+ mcp/dist/
39
+ mcp/**/__pycache__/
40
+
41
+ # web app: what the end-to-end tests leave behind
42
+ webapp/e2e/.outbox/
43
+ webapp/e2e/.results/
44
+ webapp/e2e/.outbox-*/
45
+ webapp/e2e/.results-*/
46
+ .next-*/
47
+ webapp/e2e/.report/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Elite360 Corporation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.5
2
+ Name: attestari-mcp
3
+ Version: 0.1.0
4
+ Summary: Attestari's verdict on an npm or PyPI package version, over MCP: the lookup API's answer, asked before a package is installed.
5
+ Project-URL: Homepage, https://attestari.ai
6
+ Project-URL: Documentation, https://attestari.ai/pricing#line-2
7
+ Author: Attestari
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: attestari,mcp,model-context-protocol,security,supply-chain
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Security
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: httpx2>=2.13
17
+ Requires-Dist: mcp<3,>=2.3
18
+ Requires-Dist: pydantic>=2.9
19
+ Requires-Dist: starlette>=0.40
20
+ Requires-Dist: uvicorn>=0.30
21
+ Description-Content-Type: text/markdown
22
+
23
+ # attestari-mcp
24
+
25
+ <!-- mcp-name: ai.attestari/attestari -->
26
+
27
+ Attestari's verdict on an npm or PyPI package version, over the Model Context Protocol. The answer is the Attestari
28
+ lookup API's: the grade, the decision, the confirmed findings by reason code and place, and the methodology version
29
+ and document hash the decision cites. "Not examined" is its own answer and is never a sign that a package is safe.
30
+
31
+ Every lookup needs an Attestari API key (the Developer tier and above, https://attestari.ai/pricing#line-2) and is
32
+ counted as one lookup, as through the API. What is sent is the ecosystem, the package name and the version, and
33
+ nothing else.
34
+
35
+ ## Run it locally
36
+
37
+ ```
38
+ ATTESTARI_API_KEY=att_live_... uvx attestari-mcp
39
+ ```
40
+
41
+ It speaks MCP over standard input and output and opens no port. In an MCP client's configuration:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "attestari": {
47
+ "command": "uvx",
48
+ "args": ["attestari-mcp"],
49
+ "env": { "ATTESTARI_API_KEY": "att_live_..." }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ `ATTESTARI_API_URL` changes the lookup API's address (default `https://api.attestari.ai`).
56
+
57
+ ## Tools
58
+
59
+ - `lookup_package`: one package (`ecosystem`: `npm` or `pypi`; `name`; `version`, or none for the latest version
60
+ examined).
61
+ - `lookup_packages`: up to 20 packages at once, each counted as one lookup.
62
+
63
+ Both only read. Each answer says its outcome: `graded`, `not_gradeable` (examined, no opinion given), `not_a_server`
64
+ (out of scope), `not_examined`, `under_re_examination`, `refused` (the key, the plan or a limit) or `unavailable`.
65
+
66
+ ## What the answer carries from a package
67
+
68
+ Its name, its version, and the file paths of its findings and capabilities with their line numbers. No excerpt of a
69
+ package's code or text is passed on: text written by a package's publisher never reaches the agent through this
70
+ server.
71
+
72
+ ## Licence
73
+
74
+ MIT.
@@ -0,0 +1,52 @@
1
+ # attestari-mcp
2
+
3
+ <!-- mcp-name: ai.attestari/attestari -->
4
+
5
+ Attestari's verdict on an npm or PyPI package version, over the Model Context Protocol. The answer is the Attestari
6
+ lookup API's: the grade, the decision, the confirmed findings by reason code and place, and the methodology version
7
+ and document hash the decision cites. "Not examined" is its own answer and is never a sign that a package is safe.
8
+
9
+ Every lookup needs an Attestari API key (the Developer tier and above, https://attestari.ai/pricing#line-2) and is
10
+ counted as one lookup, as through the API. What is sent is the ecosystem, the package name and the version, and
11
+ nothing else.
12
+
13
+ ## Run it locally
14
+
15
+ ```
16
+ ATTESTARI_API_KEY=att_live_... uvx attestari-mcp
17
+ ```
18
+
19
+ It speaks MCP over standard input and output and opens no port. In an MCP client's configuration:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "attestari": {
25
+ "command": "uvx",
26
+ "args": ["attestari-mcp"],
27
+ "env": { "ATTESTARI_API_KEY": "att_live_..." }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ `ATTESTARI_API_URL` changes the lookup API's address (default `https://api.attestari.ai`).
34
+
35
+ ## Tools
36
+
37
+ - `lookup_package`: one package (`ecosystem`: `npm` or `pypi`; `name`; `version`, or none for the latest version
38
+ examined).
39
+ - `lookup_packages`: up to 20 packages at once, each counted as one lookup.
40
+
41
+ Both only read. Each answer says its outcome: `graded`, `not_gradeable` (examined, no opinion given), `not_a_server`
42
+ (out of scope), `not_examined`, `under_re_examination`, `refused` (the key, the plan or a limit) or `unavailable`.
43
+
44
+ ## What the answer carries from a package
45
+
46
+ Its name, its version, and the file paths of its findings and capabilities with their line numbers. No excerpt of a
47
+ package's code or text is passed on: text written by a package's publisher never reaches the agent through this
48
+ server.
49
+
50
+ ## Licence
51
+
52
+ MIT.
@@ -0,0 +1,3 @@
1
+ """attestari-mcp: Attestari's verdict on an npm or PyPI package version, over the Model Context Protocol."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,17 @@
1
+ """The agent's address, as the MCP server's balancer saw it (docs/app.attestari.ai/phase-1-mcp-plan.md, decision 11).
2
+
3
+ The same rule the lookup API reads its own callers' addresses by (attestari_api/app.py, `client_address`, with the
4
+ last entry): Google's front end writes the connection's address last in `X-Forwarded-For`, after anything the caller
5
+ sent, so a prefix the caller wrote buys nothing; through a load balancer the list ends `<client>,<balancer>`, and a
6
+ last entry that is one of the balancer's own addresses is set aside, so the entry before it is the client's. The
7
+ lookup API then counts the call against this address (an IPv6 one by its /64), never against the MCP server's.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ def agent_address(forwarded_for: str | None, edge: frozenset[str], fallback: str = "unknown") -> str:
14
+ forwarded = [p.strip() for p in (forwarded_for or "").split(",") if p.strip()]
15
+ if len(forwarded) > 1 and forwarded[-1] in edge:
16
+ forwarded = forwarded[:-1]
17
+ return forwarded[-1] if forwarded else fallback
@@ -0,0 +1,240 @@
1
+ """What a tool answers: a projection of the lookup API's answer (docs/app.attestari.ai/mcp-threat-model.md, T8).
2
+
3
+ The lookup API's full verdict carries a package's own bytes in a few places: each capability's `excerpt` (the call as
4
+ written) and `target`, the delegation's `module` and `spec`. A tool's answer leaves every one of them out. What it
5
+ keeps from a package is the package's name and version and the file paths of its findings and capabilities, each with
6
+ its line; every other text in it is Attestari's (rule texts, reason codes, the sentences below). An agent reading the
7
+ answer reads nothing a package's publisher wrote but names, versions and paths.
8
+
9
+ "Not examined" is an outcome of its own, and so are a refusal and an outage: no answer is ever an empty success.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Any, Literal, cast
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ Outcome = Literal["graded", "not_gradeable", "not_a_server", "not_examined", "under_re_examination", "refused", "unavailable"]
20
+
21
+ SENTENCES: dict[str, str] = {
22
+ "graded": "Examined and graded under the methodology named.",
23
+ "not_gradeable": "Examined, but not graded: a guard of the methodology failed and no opinion is given. Not graded is never safe.",
24
+ "not_a_server": "Examined: this package is not an MCP server, which is outside what Attestari grades.",
25
+ "not_examined": "Attestari has not examined this package version. Not examined is never safe.",
26
+ "under_re_examination": "The decision is under re-examination: no grade is served until it is decided again.",
27
+ }
28
+ # The refusals the lookup API names, in Attestari's words.
29
+ REFUSALS: dict[str, str] = {
30
+ "key_required": "The lookup API needs an Attestari API key.",
31
+ "invalid_key": "The Attestari API key was refused: it does not exist or was revoked.",
32
+ "subscription_required": "The organization's plan includes no lookups.",
33
+ "trial_allowance_reached": "The trial's lookups for this month are used; lookups resume when the plan is paid or next month.",
34
+ "rate_limited": "Too many lookups a minute for this key or address; retry after the time given.",
35
+ "bad_request": "The package was not asked for as the lookup API reads it.",
36
+ }
37
+ UNAVAILABLE = "The Attestari lookup API did not answer just now; nothing is known of this package from this call."
38
+ # As the lookup API reads them (attestari_api/app.py: SEVERITY_ORDER, MDT, MDT_CATEGORIES).
39
+ SEVERITY_ORDER = ("info", "low", "medium", "high")
40
+ MDT = "model_directed_text"
41
+ MDT_CATEGORIES = ("exfiltrate_data", "conceal_from_user", "override_user")
42
+
43
+
44
+ class Methodology(BaseModel):
45
+ version: str
46
+ document_sha256: str
47
+ url: str
48
+
49
+
50
+ class FindingRef(BaseModel):
51
+ check: str = Field(description="the check that made the finding (Attestari's name)")
52
+ severity: str
53
+ decision: str = Field(description="confirmed, noted, or withdrawn_on_appeal")
54
+ rule: str = Field(description="the rule, in Attestari's words")
55
+ location: str | None = Field(description="file:line in the package (the path is the package's)")
56
+ evidence_id: str = Field(description="SHA-256 of the evidence the finding was verified against")
57
+
58
+
59
+ class CapabilityRef(BaseModel):
60
+ kind: str = Field(description="network, filesystem, process, environment, credentials or remote_code")
61
+ tag: str
62
+ location: str = Field(description="file:line in the package (the path is the package's)")
63
+ evidence_id: str
64
+
65
+
66
+ class PackageAnswer(BaseModel):
67
+ ecosystem: str
68
+ name: str
69
+ version: str | None = Field(description="the version the answer is about; the version asked for when none was examined")
70
+ outcome: Outcome
71
+ summary: str = Field(description="the outcome in one sentence, in Attestari's words")
72
+ grade: str | None = Field(default=None, description="A, B, C or F when graded; never given otherwise")
73
+ reason_codes: list[str] = Field(default_factory=list[str])
74
+ finding_count: int | None = Field(default=None, description="confirmed findings")
75
+ highest_severity: str | None = None
76
+ classes: list[str] = Field(default_factory=list[str], description="the model-directed categories of the confirmed findings")
77
+ findings: list[FindingRef] = Field(default_factory=list[FindingRef])
78
+ capabilities: list[CapabilityRef] = Field(default_factory=list[CapabilityRef], description="what the source can reach; never a finding")
79
+ evidence_available_on: str | None = Field(default=None, description="when a high or medium finding's evidence is published (day 30)")
80
+ methodology: Methodology | None = None
81
+ run_id: str | None = None
82
+ decided_at: str | None = None
83
+ notice: str | None = Field(default=None, description="set when the newest examination was incomplete on Attestari's side")
84
+ retry_after: int | None = Field(default=None, description="seconds, when the lookup was refused for a rate")
85
+
86
+
87
+ def _dict(value: Any) -> dict[str, Any]:
88
+ return cast(dict[str, Any], value) if isinstance(value, dict) else {}
89
+
90
+
91
+ def _dicts(value: Any) -> list[dict[str, Any]]:
92
+ return [cast(dict[str, Any], v) for v in cast(list[Any], value) if isinstance(v, dict)] if isinstance(value, list) else []
93
+
94
+
95
+ def _strs(value: Any) -> list[str]:
96
+ return [str(v) for v in cast(list[Any], value)] if isinstance(value, list) else []
97
+
98
+
99
+ def _str(value: Any) -> str | None:
100
+ return value if isinstance(value, str) else None
101
+
102
+
103
+ def _int(value: Any) -> int | None:
104
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
105
+
106
+
107
+ def _methodology(value: Any) -> Methodology | None:
108
+ m = _dict(value)
109
+ version, sha, url = _str(m.get("version")), _str(m.get("document_sha256")), _str(m.get("url"))
110
+ return Methodology(version=version, document_sha256=sha, url=url) if version and sha and url else None
111
+
112
+
113
+ def project(status: int, body: Any, ecosystem: str, name: str, version: str | None) -> PackageAnswer:
114
+ """The tool's answer to one lookup, from the lookup API's status and body. Unknown shapes are an outage, never a
115
+ success: what cannot be read is not passed on."""
116
+ said = _dict(body)
117
+ kind = said.get("status")
118
+
119
+ def answer(outcome: Outcome, summary: str, served: str | None = version) -> PackageAnswer:
120
+ return PackageAnswer(ecosystem=ecosystem, name=name, version=served, outcome=outcome, summary=summary)
121
+
122
+ if status == 404 and kind == "not_examined":
123
+ return answer("not_examined", SENTENCES["not_examined"])
124
+ if status in (400, 401, 402, 403, 422, 429) or kind in REFUSALS:
125
+ reason = kind if isinstance(kind, str) and kind in REFUSALS else ("rate_limited" if status == 429 else "bad_request")
126
+ return answer("refused", REFUSALS[reason]).model_copy(
127
+ update={"reason_codes": [reason], "retry_after": _int(said.get("retry_after"))}
128
+ )
129
+ subject = _dict(said.get("subject"))
130
+ if status != 200 or not subject:
131
+ return answer("unavailable", UNAVAILABLE)
132
+
133
+ served = _str(subject.get("version")) or version
134
+ methodology = _methodology(said.get("methodology"))
135
+ if kind == "under_re_examination":
136
+ return answer("under_re_examination", SENTENCES["under_re_examination"], served).model_copy(
137
+ update={"methodology": methodology, "run_id": _str(said.get("run_id")), "decided_at": _str(said.get("decided_at"))}
138
+ )
139
+ decision = said.get("decision")
140
+ if kind != "examined" or decision not in ("graded", "not_gradeable", "not_a_server"):
141
+ return answer("unavailable", UNAVAILABLE)
142
+
143
+ outcome = cast(Outcome, decision)
144
+ if not _vouched(
145
+ answer(outcome, SENTENCES[outcome], served).model_copy(
146
+ update={"grade": _str(said.get("grade")) if outcome == "graded" else None, "methodology": methodology}
147
+ ),
148
+ subject,
149
+ ecosystem,
150
+ name,
151
+ version,
152
+ ):
153
+ return answer("unavailable", UNAVAILABLE)
154
+ base = answer(outcome, SENTENCES[outcome], served).model_copy(
155
+ update={
156
+ "grade": _str(said.get("grade")) if outcome == "graded" else None,
157
+ "methodology": methodology,
158
+ "notice": _str(said.get("notice")),
159
+ }
160
+ )
161
+
162
+ if isinstance(said.get("findings"), list):
163
+ # The full verdict, projected: every field that carries a package's own bytes is left out (T8).
164
+ findings = _dicts(said.get("findings"))
165
+ confirmed = [f for f in findings if f.get("decision") == "confirmed"]
166
+ severities = [str(f.get("severity")) for f in confirmed]
167
+ provenance = _dict(said.get("provenance"))
168
+ return base.model_copy(
169
+ update={
170
+ "reason_codes": _strs(said.get("reason_codes")),
171
+ "finding_count": len(confirmed),
172
+ "highest_severity": max(severities, key=_rank) if severities else None,
173
+ "classes": sorted({t for f in confirmed if f.get("check") == MDT for t in _strs(f.get("tags")) if t in MDT_CATEGORIES}),
174
+ "findings": [
175
+ FindingRef(
176
+ check=str(f.get("check")),
177
+ severity=str(f.get("severity")),
178
+ decision=str(f.get("decision")),
179
+ rule=str(f.get("rule")),
180
+ location=_place(f.get("location")),
181
+ evidence_id=str(f.get("evidence_id")),
182
+ )
183
+ for f in findings
184
+ ],
185
+ "capabilities": [
186
+ CapabilityRef(
187
+ kind=str(c.get("kind")),
188
+ tag=str(c.get("tag")),
189
+ location=_place(c.get("location")) or "",
190
+ evidence_id=str(c.get("evidence_id")),
191
+ )
192
+ for c in _dicts(said.get("capabilities"))
193
+ ],
194
+ "run_id": _str(provenance.get("run_id")),
195
+ "decided_at": _str(provenance.get("decided_at")),
196
+ }
197
+ )
198
+
199
+ # The summary: what a key whose evidence waits for day 30 is answered, with the date.
200
+ return base.model_copy(
201
+ update={
202
+ "finding_count": _int(said.get("finding_count")),
203
+ "highest_severity": _str(said.get("highest_severity")),
204
+ "classes": _strs(said.get("classes")),
205
+ "evidence_available_on": _str(said.get("evidence_available_on")),
206
+ "decided_at": _str(said.get("decided_at")),
207
+ }
208
+ )
209
+
210
+
211
+ GRADES = ("A", "B", "C", "F")
212
+ PLACE_AT_MOST = 300
213
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f]")
214
+
215
+
216
+ def _place(value: Any) -> str | None:
217
+ """A file:line from a package: its publisher's text, bounded and without control characters (M-12)."""
218
+ text = _str(value)
219
+ return None if text is None else _CONTROL.sub("", text)[:PLACE_AT_MOST]
220
+
221
+
222
+ def _normal(ecosystem: str, name: str) -> str:
223
+ return re.sub(r"[-_.]+", "-", name).lower() if ecosystem == "pypi" else name
224
+
225
+
226
+ def _vouched(answer: PackageAnswer, subject: dict[str, Any], ecosystem: str, name: str, version: str | None) -> bool:
227
+ """What a "graded" answer must hold before it is passed on (M-5): a grade of the ladder, the methodology, and the
228
+ package and version asked about."""
229
+ said_name, said_version = _str(subject.get("name")), _str(subject.get("version"))
230
+ if said_name is None or _normal(ecosystem, said_name) != _normal(ecosystem, name) or subject.get("ecosystem") != ecosystem:
231
+ return False
232
+ if version is not None and said_version != version:
233
+ return False
234
+ if answer.outcome == "graded" and (answer.grade not in GRADES or answer.methodology is None):
235
+ return False
236
+ return answer.methodology is not None
237
+
238
+
239
+ def _rank(severity: str) -> int:
240
+ return SEVERITY_ORDER.index(severity) if severity in SEVERITY_ORDER else len(SEVERITY_ORDER)
@@ -0,0 +1,182 @@
1
+ """Asking the lookup API (https://api.attestari.ai) about a package, and nothing else.
2
+
3
+ Two ways, one for each way the server runs:
4
+
5
+ - **Direct** (the package run locally, over stdio): the customer's key in `Authorization`, as a program of theirs
6
+ would send it. The lookup API counts and limits the call as any keyed call, against the customer's own address.
7
+ - **On an agent's behalf** (the remote server at mcp.attestari.ai; plan decision 11): the MCP server's own Google
8
+ identity token in `Authorization`, and the agent's key (`Attestari-Agent: key att_live_...`) and address
9
+ (`Attestari-Agent-Address`) beside it. The lookup API believes those two headers from the MCP server's service
10
+ account alone, and every bucket it then takes is the agent's: its key's, or its address's for a key refused or not
11
+ yet remembered. The MCP server's own address is charged nothing.
12
+
13
+ What is sent is the ecosystem, the name and the version (price book line 2). Nothing here logs a key or a token.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import base64
20
+ import json
21
+ import logging
22
+ import re
23
+ import time
24
+ from dataclasses import dataclass
25
+ from typing import Any, Protocol
26
+ from urllib.parse import quote
27
+
28
+ import httpx2
29
+
30
+ from attestari_mcp import __version__
31
+ from attestari_mcp.answer import PackageAnswer, project
32
+
33
+ KEY = re.compile(r"^att_live_[0-9A-Za-z]{43}$")
34
+ # What the lookup API can be asked about, checked before anything is sent: a name or version of another shape is
35
+ # answered "refused" here and never reaches the API.
36
+ # npm's own rule (MCP review M-4): no name or scope begins with "." or "_", so no segment is "." or "..".
37
+ NPM_NAME = re.compile(r"^(@[A-Za-z0-9~-][A-Za-z0-9._~-]{0,212}/)?[A-Za-z0-9~-][A-Za-z0-9._~-]{0,213}$")
38
+ PYPI_NAME = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,198}[A-Za-z0-9])?$")
39
+ VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.+_!-]{0,99}$")
40
+ ECOSYSTEMS = ("npm", "pypi")
41
+ AT_ONCE = 4
42
+ TIMEOUT_SECONDS = 10.0
43
+ METADATA_IDENTITY_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity"
44
+ TOKEN_MARGIN_SECONDS = 300.0
45
+ # A refusal that names the key, not the package: the rest of a batch is answered the same without being sent (M-1).
46
+ KEY_REFUSALS = ("key_required", "invalid_key", "subscription_required", "trial_allowance_reached")
47
+ LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
48
+
49
+ log = logging.getLogger("attestari_mcp")
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Direct:
54
+ key: str
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class OnBehalf:
59
+ """`agent` is what the lookup API is told the call is for: `key att_live_...` (and, from milestone 1.3, `grant
60
+ <key row id>`); `address` is the agent's address as the MCP server's balancer saw it."""
61
+
62
+ agent: str
63
+ address: str
64
+
65
+
66
+ Credential = Direct | OnBehalf
67
+
68
+
69
+ class Identity(Protocol):
70
+ async def token(self) -> str: ...
71
+
72
+
73
+ def path_of(ecosystem: str, name: str, version: str | None) -> str | None:
74
+ """The lookup API's path for the package, or None when the package cannot be one of its ecosystem's."""
75
+ if ecosystem not in ECOSYSTEMS or len(name) > 214:
76
+ return None
77
+ if not (NPM_NAME if ecosystem == "npm" else PYPI_NAME).match(name):
78
+ return None
79
+ if version is not None and not VERSION.match(version):
80
+ return None
81
+ # A scoped npm name keeps its one slash: the API reads /v1/verdict/npm/@scope/name[/version].
82
+ path = f"/v1/verdict/{ecosystem}/" + "/".join(quote(part, safe="@") for part in name.split("/"))
83
+ path = path if version is None else f"{path}/{quote(version, safe='')}"
84
+ # The path the HTTP client will send is the path built, or nothing is sent (M-4).
85
+ return path if httpx2.URL("https://lookup.invalid" + path).raw_path.decode("ascii") == path else None
86
+
87
+
88
+ def secure_base(url: str) -> bool:
89
+ """The lookup API's address takes a key only over https, or plain http to this machine (M-11)."""
90
+ parsed = httpx2.URL(url)
91
+ return parsed.scheme == "https" or (parsed.scheme == "http" and parsed.host in LOCAL_HOSTS)
92
+
93
+
94
+ class Lookup:
95
+ def __init__(self, base_url: str, http: httpx2.AsyncClient, identity: Identity | None = None) -> None:
96
+ if not secure_base(base_url):
97
+ raise ValueError("the lookup API's address must be https")
98
+ self.base_url = base_url.rstrip("/")
99
+ self.http = http
100
+ self.identity = identity
101
+
102
+ async def one(self, ecosystem: str, name: str, version: str | None, credential: Credential) -> PackageAnswer:
103
+ path = path_of(ecosystem, name, version)
104
+ if path is None:
105
+ return project(400, {"status": "bad_request"}, ecosystem, name, version)
106
+ headers = {"Accept": "application/json", "User-Agent": f"attestari-mcp/{__version__}"}
107
+ if isinstance(credential, Direct):
108
+ headers["Authorization"] = f"Bearer {credential.key}"
109
+ else:
110
+ if self.identity is None:
111
+ return project(503, None, ecosystem, name, version)
112
+ try:
113
+ headers["Authorization"] = f"Bearer {await self.identity.token()}"
114
+ except (httpx2.HTTPError, ValueError, KeyError):
115
+ return project(503, None, ecosystem, name, version)
116
+ headers["Attestari-Agent"] = credential.agent
117
+ headers["Attestari-Agent-Address"] = credential.address
118
+ try:
119
+ answer = await self.http.get(self.base_url + path, headers=headers, timeout=TIMEOUT_SECONDS)
120
+ except httpx2.HTTPError:
121
+ return project(503, None, ecosystem, name, version)
122
+ try:
123
+ body: Any = answer.json()
124
+ except ValueError:
125
+ body = None
126
+ if isinstance(credential, OnBehalf) and answer.status_code == 400:
127
+ # The lookup API refused the MCP server's own call: a fault of ours, never the agent's request (M-6).
128
+ log.error(json.dumps({"message": "the lookup API refused the MCP server's call on an agent's behalf", "status": 400}))
129
+ return project(503, None, ecosystem, name, version)
130
+ return project(answer.status_code, body, ecosystem, name, version)
131
+
132
+ async def many(self, asked: list[tuple[str, str, str | None]], credential: Credential) -> list[PackageAnswer]:
133
+ """Each package one lookup, a few at a time, answered in the order asked. The first is asked alone: when the
134
+ key itself is refused, the rest are answered the same and never sent (M-1)."""
135
+ if not asked:
136
+ return []
137
+ first = await self.one(*asked[0], credential)
138
+ refused = [r for r in first.reason_codes if r in KEY_REFUSALS] if first.outcome == "refused" else []
139
+ if refused:
140
+ return [first] + [project(401, {"status": refused[0]}, ecosystem, name, version) for ecosystem, name, version in asked[1:]]
141
+ gate = asyncio.Semaphore(AT_ONCE)
142
+
143
+ async def each(one: tuple[str, str, str | None]) -> PackageAnswer:
144
+ async with gate:
145
+ return await self.one(*one, credential)
146
+
147
+ return [first, *await asyncio.gather(*(each(one) for one in asked[1:]))]
148
+
149
+
150
+ class MetadataIdentity:
151
+ """The MCP server's own Google identity token for the lookup API (Cloud Run's metadata server), kept until five
152
+ minutes before it expires."""
153
+
154
+ def __init__(self, audience: str, http: httpx2.AsyncClient) -> None:
155
+ self.audience = audience
156
+ self.http = http
157
+ self._token: str | None = None
158
+ self._expires = 0.0
159
+ self._lock = asyncio.Lock()
160
+
161
+ async def token(self) -> str:
162
+ async with self._lock:
163
+ if self._token is None or time.time() > self._expires - TOKEN_MARGIN_SECONDS:
164
+ answer = await self.http.get(
165
+ METADATA_IDENTITY_URL,
166
+ params={"audience": self.audience, "format": "full"},
167
+ headers={"Metadata-Flavor": "Google"},
168
+ timeout=TIMEOUT_SECONDS,
169
+ )
170
+ answer.raise_for_status()
171
+ self._token = answer.text.strip()
172
+ self._expires = float(_claims(self._token)["exp"])
173
+ return self._token
174
+
175
+
176
+ def _claims(token: str) -> dict[str, Any]:
177
+ """The payload of a JWT, read without checking it: only to know when our own token expires."""
178
+ payload = token.split(".")[1]
179
+ claims: Any = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
180
+ if not isinstance(claims, dict):
181
+ raise ValueError("not a token")
182
+ return claims # pyright: ignore[reportUnknownVariableType]