rtings-mcp 0.2.1__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.
rtings_mcp/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """rtings-mcp — RTINGS.com test data as structured MCP tools.
2
+
3
+ The one thing to know before reading anything else: RTINGS gates some measurements
4
+ server-side, **per category**, and this server's whole job is to be structurally incapable of
5
+ lying about that. A gated value comes back ``null`` with ``status: "tested_gated"`` — which
6
+ is a different fact from ``not_tested``, ``not_applicable``, ``review_unpublished`` and
7
+ ``coverage_unknown``, and the code never collapses two of them into one null.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.2.1"
13
+
14
+ __all__ = ["__version__"]
rtings_mcp/api.py ADDED
@@ -0,0 +1,242 @@
1
+ """Typed wrappers for the ``/api/v2/safe/`` queries (RECON §1).
2
+
3
+ **Request shapes are per-query — do not generalize by prefix.** ``table_tool__*`` take
4
+ ``{"variables": {...}}``; ``app/product_vue_page__page_body`` takes
5
+ ``{"variables": {...}, share_token, url_path}``; ``app/search__search_results`` takes a
6
+ **bare** ``{count, is_admin, query, type}`` with no ``variables`` wrapper.
7
+
8
+ Two request-body rules:
9
+
10
+ * ``named_version`` is always ``"public"``. ``is_admin`` is always ``false`` — that is what
11
+ the logged-out front end sends, and omitting the key would produce a body the real client
12
+ never sends. Never a privileged *value* (``is_admin:true``, ``named_version:"admin"``).
13
+ * ``force_blur:false`` and ``unblur_product_ids:[]`` are sent exactly as the front end sends
14
+ them. Populating ``unblur_product_ids`` beyond that is a client unblur hint, i.e.
15
+ circumvention — and it was measured to be ignored anonymously anyway.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Sequence
21
+ from typing import Any
22
+
23
+ from . import errors
24
+ from .config import BASE_URL
25
+ from .errors import RtingsError
26
+ from .http import Transport
27
+
28
+ NAMED_VERSION = "public"
29
+
30
+ Q_COLUMN_OPTIONS = "table_tool__column_options"
31
+ Q_PRODUCTS_LIST = "table_tool__products_list"
32
+ Q_TEST_RESULTS = "table_tool__test_results"
33
+ Q_RATINGS = "table_tool__ratings"
34
+ Q_GRAPH_URL = "graph_tool__product_graph_data_url"
35
+ Q_SEARCH = "app/search__search_results"
36
+ Q_PAGE_BODY = "app/product_vue_page__page_body"
37
+ Q_SIDE_BY_SIDE = "app/side_by_side__review"
38
+
39
+ #: ``test_results`` scales with rows, so the request side caps the test count to keep the
40
+ #: response inside the session's size ceiling.
41
+ MAX_TESTS_PER_REQUEST = 60
42
+
43
+
44
+ def _dig(payload: Any, *path: str, query: str) -> Any:
45
+ """Walk ``data.<key>...``, raising the drift alarm rather than degrading to empty."""
46
+ node = payload
47
+ for key in ("data", *path):
48
+ if not isinstance(node, dict) or key not in node:
49
+ raise RtingsError(
50
+ errors.PAYLOAD_MISSING,
51
+ f"{query}: expected data.{'.'.join(path)} in the response",
52
+ )
53
+ node = node[key]
54
+ return node
55
+
56
+
57
+ async def column_options(transport: Transport, silo: str) -> dict[str, Any]:
58
+ """The silo schema — the one fixed ~357 KB payload, fetched once per silo."""
59
+ payload = await transport.api_post(
60
+ Q_COLUMN_OPTIONS,
61
+ {"variables": {"silo_url_part": silo, "named_version": NAMED_VERSION}},
62
+ referer=f"{BASE_URL}/{silo}/tools/table",
63
+ )
64
+ return _dig(payload, "silo", query=Q_COLUMN_OPTIONS)
65
+
66
+
67
+ async def products_list(
68
+ transport: Transport, silo: str, bench_ids: Sequence[str]
69
+ ) -> list[dict[str, Any]]:
70
+ """The catalog for a bench set. Carries ``published`` — the second, orthogonal blur."""
71
+ payload = await transport.api_post(
72
+ Q_PRODUCTS_LIST,
73
+ {
74
+ "variables": {
75
+ "test_bench_ids": [str(b) for b in bench_ids],
76
+ "named_version": NAMED_VERSION,
77
+ "is_admin": False,
78
+ }
79
+ },
80
+ referer=f"{BASE_URL}/{silo}/tools/table",
81
+ )
82
+ products = _dig(payload, "products", query=Q_PRODUCTS_LIST)
83
+ if not isinstance(products, list):
84
+ raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_PRODUCTS_LIST}: products is not a list")
85
+ return products
86
+
87
+
88
+ async def test_results(
89
+ transport: Transport,
90
+ silo: str,
91
+ bench_ids: Sequence[str],
92
+ original_ids: Sequence[str],
93
+ ) -> list[dict[str, Any]]:
94
+ """Scalar measurements for ``(benches x tests)``, **all products** — there is no product
95
+ filter, which is precisely why the response is the coverage record."""
96
+ payload = await transport.api_post(
97
+ Q_TEST_RESULTS,
98
+ {
99
+ "variables": {
100
+ "test_bench_ids": [str(b) for b in bench_ids],
101
+ "original_ids": [str(t) for t in original_ids],
102
+ "named_version": NAMED_VERSION,
103
+ "is_admin": False,
104
+ "force_blur": False,
105
+ "unblur_product_ids": [],
106
+ }
107
+ },
108
+ referer=f"{BASE_URL}/{silo}/tools/table",
109
+ )
110
+ rows = _dig(payload, "test_results", query=Q_TEST_RESULTS)
111
+ if not isinstance(rows, list):
112
+ raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_TEST_RESULTS}: test_results is not a list")
113
+ return rows
114
+
115
+
116
+ async def ratings(
117
+ transport: Transport,
118
+ silo: str,
119
+ bench_ids: Sequence[str],
120
+ original_ids: Sequence[str],
121
+ ) -> list[dict[str, Any]]:
122
+ """0-10 usage scores. Usage definitions carry no ``insider_only``, so availability here
123
+ is derived from observed ``unblurred`` only."""
124
+ payload = await transport.api_post(
125
+ Q_RATINGS,
126
+ {
127
+ "variables": {
128
+ "test_bench_ids": [str(b) for b in bench_ids],
129
+ "original_ids": [str(u) for u in original_ids],
130
+ "named_version": NAMED_VERSION,
131
+ "is_admin": False,
132
+ "force_blur": False,
133
+ "unblur_product_ids": [],
134
+ }
135
+ },
136
+ referer=f"{BASE_URL}/{silo}/tools/table",
137
+ )
138
+ rows = _dig(payload, "ratings", query=Q_RATINGS)
139
+ if not isinstance(rows, list):
140
+ raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_RATINGS}: ratings is not a list")
141
+ return rows
142
+
143
+
144
+ async def graph_data_url(
145
+ transport: Transport, silo: str, product_id: str, test_original_id: str
146
+ ) -> str | None:
147
+ """The CDN path for one ``(product, test)`` curve, or ``None`` when this product has
148
+ none — ``graph_not_available``, which is the common case, not the exception."""
149
+ payload = await transport.api_post(
150
+ Q_GRAPH_URL,
151
+ {
152
+ "variables": {
153
+ "named_version": NAMED_VERSION,
154
+ "product_id": str(product_id),
155
+ "test_original_id": str(test_original_id),
156
+ }
157
+ },
158
+ referer=f"{BASE_URL}/{silo}/graph",
159
+ )
160
+ # A genuine "no curve" is a well-formed, explicitly EMPTY list — measured 2026-09-04 on
161
+ # X90L test 11: `{"data":{"product":{"review":{"test_results":[]}}}}`. So `_dig` raising
162
+ # here means the SHAPE changed, not that the product has no curve, and the two must not
163
+ # be collapsed: `repository.graph` writes a `graph_not_available` file for the negative,
164
+ # so swallowing drift cached "RTINGS publishes no curve for this test" for three days
165
+ # off a transient blip. Let the drift alarm through — it is never cached.
166
+ results = _dig(payload, "product", "review", "test_results", query=Q_GRAPH_URL)
167
+ if not isinstance(results, list) or not results:
168
+ return None
169
+ url = results[0].get("graph_data_url") if isinstance(results[0], dict) else None
170
+ return str(url) if url else None
171
+
172
+
173
+ async def search(transport: Transport, query: str, count: int = 10) -> dict[str, Any]:
174
+ """Cross-silo live search. Bare body, **no** ``variables`` wrapper."""
175
+ payload = await transport.api_post(
176
+ Q_SEARCH,
177
+ {"count": int(count), "is_admin": False, "query": query, "type": "full"},
178
+ referer=f"{BASE_URL}/",
179
+ )
180
+ result = _dig(payload, "search_results", query=Q_SEARCH)
181
+ if not isinstance(result, dict):
182
+ raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_SEARCH}: search_results is not an object")
183
+ return result
184
+
185
+
186
+ async def side_by_side_review(
187
+ transport: Transport, silo: str, product_id: str
188
+ ) -> dict[str, Any]:
189
+ """The compare tool's view of one review — RTINGS' *words*, where the table path has
190
+ only numbers (RECON §12.16).
191
+
192
+ Bare ``{product_id}``, no ``variables`` wrapper. What it adds, anonymously, even on a
193
+ category that withholds every measurement:
194
+
195
+ * ``product_score_sets`` — the per-usage verdict prose ("good for mixed usage because…");
196
+ * ``summaries`` — the pros/cons blurbs, with ``priority`` separating them;
197
+ * ``score_sets`` — how each usage score is composed, with weights.
198
+
199
+ **Its ``test_results`` are deliberately ignored.** They are a *third* row shape carrying
200
+ no ``unblurred`` key at all, so the seven-state normalizer has nothing to branch on; the
201
+ table and review paths already answer that question properly.
202
+
203
+ **Metering is unverified.** This is the public compare tool rather than the metered
204
+ review page, and a tool that spent a preview per comparison would be unusable — but that
205
+ is reasoning, not measurement, so Phase 0 confirms it.
206
+ """
207
+ payload = await transport.api_post(
208
+ Q_SIDE_BY_SIDE,
209
+ {"product_id": str(product_id)},
210
+ referer=f"{BASE_URL}/{silo}/tools/compare",
211
+ )
212
+ review = _dig(payload, "review", query=Q_SIDE_BY_SIDE)
213
+ if not isinstance(review, dict):
214
+ raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_SIDE_BY_SIDE}: review is not an object")
215
+ return review
216
+
217
+
218
+ async def page_body(transport: Transport, url_path: str) -> dict[str, Any]:
219
+ """One full review (~442 KB).
220
+
221
+ **This is the metered endpoint.** Every guard around spending a free account's previews
222
+ lives in the calling tool; this function performs the POST and nothing else. The session
223
+ runs ``max_retries=0``, so one call is one request — the default of 3 would have been
224
+ three consumed previews.
225
+ """
226
+ path = url_path if url_path.startswith("/") else f"/{url_path}"
227
+ payload = await transport.api_post(
228
+ Q_PAGE_BODY,
229
+ {
230
+ "variables": {"url": path, "named_version": NAMED_VERSION, "version_id": None},
231
+ "share_token": None,
232
+ "url_path": path,
233
+ },
234
+ referer=f"{BASE_URL}{path}",
235
+ )
236
+ # Verified 2026-09-03: the body lands at ``data.page``, and the review itself at
237
+ # ``data.page.product.review`` (402 rows on TV bench 227, every one carrying
238
+ # ``rendered_value`` + ``score`` and **no** ``value`` key).
239
+ page = _dig(payload, "page", query=Q_PAGE_BODY)
240
+ if not isinstance(page, dict):
241
+ raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_PAGE_BODY}: page is not an object")
242
+ return page