mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
public_url/email_from defaults, render.yaml, CLI fallbacks, packaging (npm/plugin/pyproject), web pages (+canonical tag), docs and context fragments all move to https://treg.to. The legacy host keeps serving the FULL API forever — installed CLIs, skill.md files and .mcp.json configs in the wild hold Bearer tokens pointed at it, and HTTP clients strip Authorization on cross-host redirects. Only browser-facing marketing pages 301 to treg.to (new middleware + tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
857 lines
46 KiB
Python
857 lines
46 KiB
Python
"""The MCP front door — that it answers, and that it enforces the same rules as every other door.
|
|
|
|
Two halves, and the second is the one that matters. A new entrance onto a paid catalog is only safe
|
|
if it cannot see another team's data, cannot spend past the caps, and cannot be talked into serving
|
|
an endpoint whose price nobody knows. `mcp.py` gets that by routing through treg's own API rather
|
|
than reaching into the internals — these tests are what proves the routing is real and not merely
|
|
intended.
|
|
|
|
The transport is exercised as a real MCP client would: JSON-RPC over the mounted ASGI app.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from treg.api import app
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
MCP_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
|
|
|
|
|
async def _rpc(client: AsyncClient, method: str, params=None, token: str | None = None):
|
|
headers = dict(MCP_HEADERS)
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
body = {"jsonrpc": "2.0", "id": 1, "method": method}
|
|
if params is not None:
|
|
body["params"] = params
|
|
# An ABSOLUTE url so the Host header is a real one. The transport enforces DNS-rebinding
|
|
# protection, and the suite's base_url ("http://registry") is deliberately not on the
|
|
# allow-list — see test_an_unknown_host_is_refused.
|
|
r = await client.post("http://localhost/mcp/", json=body, headers=headers)
|
|
return r
|
|
|
|
|
|
async def _call_tool(client: AsyncClient, name: str, args: dict, token: str | None = None) -> dict:
|
|
await _rpc(client, "initialize", {
|
|
"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "test", "version": "1"}}, token)
|
|
r = await _rpc(client, "tools/call", {"name": name, "arguments": args}, token)
|
|
payload = r.json()
|
|
content = (payload.get("result") or {}).get("content") or []
|
|
if content and content[0].get("type") == "text":
|
|
try:
|
|
return json.loads(content[0]["text"])
|
|
except ValueError:
|
|
return {"_text": content[0]["text"]}
|
|
return payload
|
|
|
|
|
|
@asynccontextmanager
|
|
async def mcp_session(client: AsyncClient):
|
|
"""Run the MCP lifespan around a block of requests.
|
|
|
|
Deliberately NOT a fixture. The lifespan holds an anyio task group, and a fixture enters it in
|
|
one task and exits it in another — anyio refuses that ("Attempted to exit cancel scope in a
|
|
different task"). Entering it inside the test body keeps both ends in the same task.
|
|
|
|
That the mounted app needs its lifespan run at all is the trap the module docstring warns about:
|
|
`app.mount()` does not run it, and the transport builds its task group there. It caught me here
|
|
first, which is the cheapest place for it to happen.
|
|
|
|
The default `X-Treg-Token` is dropped: MCP carries identity in `Authorization`, and leaving a
|
|
second credential on the client would let a test pass for the wrong reason.
|
|
"""
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient as _AC
|
|
|
|
from treg import mcp as _mcp
|
|
|
|
# A FRESH transport per test: `StreamableHTTPSessionManager.run()` may be called once per
|
|
# instance, so the module-level one cannot be restarted between tests. The tools are the same
|
|
# objects either way — they hang off the shared `mcp` server — and they still reach the real
|
|
# `treg.api.app` internally, so the enforcement under test is the production path.
|
|
fresh = _mcp.build_mcp_app()
|
|
host = FastAPI()
|
|
host.mount("/mcp", fresh)
|
|
client.headers.pop("X-Treg-Token", None)
|
|
async with _mcp.mcp_lifespan(fresh):
|
|
async with _AC(transport=ASGITransport(app=host), base_url="http://localhost") as mc:
|
|
mc.headers.update({k: v for k, v in client.headers.items() if k.lower() == "cookie"})
|
|
yield mc
|
|
|
|
|
|
async def test_the_server_lists_exactly_the_five_tools(clients):
|
|
"""Five tools, not 2,600. The catalog is DATA reached through a tool, never a tool per endpoint —
|
|
2,600 schemas would bury the model's context and make the catalog unusable."""
|
|
token = (await clients.post("/users", json={"email": "lister@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
await _rpc(c, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "t", "version": "1"}}, token)
|
|
r = await _rpc(c, "tools/list", token=token)
|
|
names = {t["name"] for t in r.json()["result"]["tools"]}
|
|
assert names == {"catalog_search", "catalog_get", "call", "balance", "my_tools"}
|
|
|
|
|
|
async def test_catalog_search_returns_priced_results(clients):
|
|
"""Search is the entry point: an agent asks for a task and gets endpoints with prices. Needs a
|
|
credential now, like every tool — see test_EVERY_tool_needs_a_credential_including_the_catalog."""
|
|
token = (await clients.post("/users", json={"email": "searcher@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "catalog_search", {"query": "backlinks", "limit": 3}, token=token)
|
|
assert out["results"], out
|
|
first = out["results"][0]
|
|
assert first["endpoint_id"] and first["provider"]
|
|
assert "usd_per_call" in first and "no_key_needed" in first
|
|
|
|
|
|
async def test_no_key_needed_is_false_when_the_deploy_holds_no_key(clients):
|
|
"""`no_key_needed` must mean "THIS deploy will serve it on treg's key", not "the row is priced".
|
|
The test env configures no platform keys at all, so every result — however eligible its price —
|
|
must say False; anything else re-opens the advertise-then-refuse gap this field had."""
|
|
token = (await clients.post("/users", json={"email": "keyless@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "catalog_search", {"query": "backlinks", "limit": 5}, token=token)
|
|
assert out["results"]
|
|
assert all(r["no_key_needed"] is False for r in out["results"]), out["results"]
|
|
|
|
|
|
async def test_search_says_so_when_nothing_matches(clients):
|
|
token = (await clients.post("/users", json={"email": "nomatch@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "catalog_search", {"query": "zzzz-no-such-capability"}, token=token)
|
|
assert out["count"] == 0
|
|
assert "hint" in out
|
|
|
|
|
|
# ---- the half that matters: no token means no data, no spending ----------------------------
|
|
|
|
@pytest.mark.parametrize("tool", ["call", "balance", "my_tools"])
|
|
async def test_every_spending_or_tenant_tool_refuses_without_a_token(clients, tool):
|
|
"""A public MCP endpoint onto a paid catalog is the whole risk of this feature. Anything that
|
|
reads a team's data or moves its money must fail closed, and say what to do about it.
|
|
|
|
The refusal is now an HTTP 401 carrying `WWW-Authenticate`, rather than an error dict inside a
|
|
200 — see `test_a_protected_tool_answers_401_with_WWW_Authenticate` for why the shape matters.
|
|
This test keeps its original job: proving these three cannot be reached without a credential."""
|
|
args = {"endpoint_id": "tikhub.tiktok.video.comments"} if tool == "call" else {}
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": {"name": tool, "arguments": args}}, headers=MCP_HEADERS)
|
|
assert r.status_code == 401, r.text
|
|
assert "resource_metadata" in r.headers.get("www-authenticate", "")
|
|
|
|
|
|
async def test_a_bogus_token_gets_nothing(clients):
|
|
"""Headers are client-supplied input. A well-formed but unknown token must be rejected by the
|
|
database, not accepted because it looks like one."""
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "balance", {}, token="tok_not_a_real_token_at_all")
|
|
assert "balance_usd" not in out, out
|
|
assert out.get("error")
|
|
|
|
|
|
async def test_a_real_token_reads_its_OWN_balance(clients):
|
|
"""The positive case, and the one that proves the plumbing: a genuine token resolves to its org
|
|
through `/auth/me` and reads that org's balance — the same route the CLI uses."""
|
|
token = (await clients.post("/auth/cli-token")).json()["token"] if False else None
|
|
# the suite's client was created with a real per-org token; recover it before it is dropped
|
|
r = await clients.post("/users", json={"email": "mcpuser@superdesign.dev"})
|
|
token = r.json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "balance", {}, token=token)
|
|
assert "balance_usd" in out, out
|
|
assert out["balance_usd"] >= 0
|
|
|
|
|
|
async def test_an_IDENTITY_token_resolves_its_team(clients):
|
|
"""The bug production found. There are two kinds of token: a PER-ORG token (`treg org agent-new`)
|
|
has its team baked in and `/auth/me` reports it; an IDENTITY token (`treg login` — what most
|
|
people actually hold) belongs to a person who may be in several teams, so `/auth/me` reports no
|
|
org and every `/orgs/{id}/…` route must be told which one. Resolving only the first kind meant
|
|
`balance` answered "could not resolve the team" for the commonest token there is."""
|
|
r = await clients.post("/users", json={"email": "identity-user@superdesign.dev"})
|
|
per_org = r.json()["token"]
|
|
clients.headers["X-Treg-Token"] = per_org
|
|
identity = (await clients.get("/auth/cli-token")).json()["token"]
|
|
assert identity != per_org
|
|
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "balance", {}, token=identity)
|
|
assert "balance_usd" in out, out
|
|
assert out.get("team")
|
|
|
|
|
|
async def test_one_team_cannot_read_another_teams_tools(clients):
|
|
"""Tenant isolation, asserted rather than assumed. Two orgs, two tokens: each sees only its own
|
|
registered tools. This is the property a second front door is most likely to lose."""
|
|
a = (await clients.post("/users", json={"email": "org-a@superdesign.dev"})).json()["token"]
|
|
b = (await clients.post("/users", json={"email": "org-b@superdesign.dev"})).json()["token"]
|
|
clients.headers["X-Treg-Token"] = a
|
|
made = await clients.post("/tools", json={"name": "a-only-tool", "base_url": "http://upstream"})
|
|
assert made.status_code == 200, made.text
|
|
|
|
async with mcp_session(clients) as c:
|
|
seen_a = await _call_tool(c, "my_tools", {}, token=a)
|
|
seen_b = await _call_tool(c, "my_tools", {}, token=b)
|
|
names_a = {t["name"] for t in seen_a.get("tools", [])}
|
|
names_b = {t["name"] for t in seen_b.get("tools", [])}
|
|
# Not vacuous: org A must genuinely SEE its tool, or "B cannot see it" proves nothing.
|
|
assert names_a, f"org A saw no tools at all — the assertion below would pass for free: {seen_a}"
|
|
assert "a-only-tool" in names_a
|
|
assert "a-only-tool" not in names_b, f"org B saw org A's tool: {seen_b}"
|
|
|
|
|
|
async def test_call_reaches_the_TEAMS_OWN_tools_too(clients):
|
|
"""`my_tools` lists what the team registered; `call` must be able to call it.
|
|
|
|
The first version pre-checked the catalog and refused anything absent, which made `my_tools` a
|
|
list of things an agent could see and never use — found by trying it on production. `/call/`
|
|
already resolves a team's own tool first and falls back to a catalog id, so the fix was to stop
|
|
second-guessing it."""
|
|
made = await clients.post("/tools", json={"name": "echo", "base_url": "http://upstream"})
|
|
assert made.status_code == 200, made.text
|
|
token = clients.headers.get("X-Treg-Token")
|
|
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "call", {"endpoint_id": "echo/anything"}, token=token)
|
|
assert out.get("status") == 200, out
|
|
assert "unknown endpoint" not in json.dumps(out)
|
|
|
|
|
|
async def test_call_refuses_an_endpoint_that_does_not_exist(clients):
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "call", {"endpoint_id": "nope.not.real"}, token="tok_whatever")
|
|
assert "unknown endpoint" in out.get("error", "")
|
|
assert "catalog_search" in out.get("hint", "") # names the way out, rather than leaving it guessing
|
|
|
|
|
|
async def test_tool_descriptions_do_not_promise_routing(clients):
|
|
"""The charter's standing rule, and the one the landing page already had to be corrected for:
|
|
treg COMPARES providers and the caller chooses. These descriptions are read by every model that
|
|
installs the plugin, so a false claim here travels further than the website's did."""
|
|
token = (await clients.post("/users", json={"email": "descs@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
await _rpc(c, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "t", "version": "1"}}, token)
|
|
r = await _rpc(c, "tools/list", token=token)
|
|
blob = json.dumps(r.json()).lower()
|
|
for claim in ("routes for you", "automatic failover", "fails over", "picks the best provider"):
|
|
assert claim not in blob
|
|
|
|
|
|
async def test_an_unknown_host_is_refused(clients):
|
|
"""The SDK's DNS-rebinding protection, proven live rather than trusted.
|
|
|
|
It ships ON with an EMPTY allow-list, which 421s EVERYTHING — a deploy looks healthy until the
|
|
first tool call. `mcp._allowed_hosts()` builds the list from this deployment's `public_url` plus
|
|
the loopback names, so this asserts both directions: a known host works (every other test) and an
|
|
unknown one does not."""
|
|
token = (await clients.post("/users", json={"email": "host@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://evil.example.com/mcp/", json={"jsonrpc": "2.0", "id": 1,
|
|
"method": "tools/list"},
|
|
headers={**MCP_HEADERS, "Authorization": f"Bearer {token}"})
|
|
assert r.status_code == 421, r.text # a credential passes auth; the HOST guard still refuses
|
|
|
|
|
|
async def test_the_deployments_own_host_is_allowed():
|
|
"""The list must contain the host treg actually answers on, or production 421s every call."""
|
|
from urllib.parse import urlsplit
|
|
|
|
from treg.config import get_settings
|
|
from treg.mcp import _allowed_hosts
|
|
|
|
assert urlsplit(get_settings().public_url).netloc in _allowed_hosts()
|
|
|
|
|
|
async def test_the_price_is_visible_before_spending(clients):
|
|
"""An agent that cannot see a price before calling cannot warn the human, and the skill's rule is
|
|
to state the cost first. Search must carry the number."""
|
|
token = (await clients.post("/users", json={"email": "pricer@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "catalog_search", {"query": "backlinks", "limit": 5}, token=token)
|
|
assert any(r.get("usd_per_call") is not None for r in out["results"])
|
|
|
|
|
|
# ---- what the plugin directory's review checks ---------------------------------------------
|
|
|
|
async def test_every_tool_declares_what_it_can_do(clients):
|
|
"""The submission portal validates `readOnlyHint`/`openWorldHint`/`destructiveHint` against the
|
|
tool's real behaviour, and a model consults them before acting. The four reading tools change
|
|
nothing; `call` is the honest exception — it relays whatever the caller asks to whichever
|
|
upstream the endpoint names, which can be a POST that publishes, an email that sends or a DELETE.
|
|
treg does not model the upstream, so it cannot promise the call is safe, and saying otherwise
|
|
here would be a false assurance in the exact place it matters."""
|
|
from treg.mcp import mcp as server
|
|
|
|
ann = {t.name: t.annotations for t in await server.list_tools()}
|
|
assert set(ann) == {"catalog_search", "catalog_get", "call", "balance", "my_tools"}
|
|
for name in ("catalog_search", "catalog_get", "balance", "my_tools"):
|
|
a = ann[name]
|
|
assert a and a.read_only_hint is True, name
|
|
assert a.destructive_hint is False and a.open_world_hint is False, name
|
|
a = ann["call"]
|
|
assert a.read_only_hint is False
|
|
assert a.destructive_hint is True and a.open_world_hint is True
|
|
|
|
|
|
async def test_the_domain_challenge_is_404_until_configured(clients):
|
|
"""Empty means unset, and unset must 404. An empty 200 would read to the portal as a
|
|
verification that never completes, which is harder to debug than a plain absence."""
|
|
r = await clients.get("/.well-known/openai-apps-challenge")
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_the_domain_challenge_returns_the_token_ALONE(clients, monkeypatch):
|
|
"""The portal is explicit: that URL must return the token and nothing else — not JSON, not a
|
|
list, not several tokens. Returning a JSON body here is a documented rejection."""
|
|
from treg import config
|
|
|
|
settings = config.get_settings()
|
|
monkeypatch.setattr(settings, "openai_apps_challenge", "tok-abc-123", raising=False)
|
|
r = await clients.get("/.well-known/openai-apps-challenge")
|
|
assert r.status_code == 200
|
|
assert r.text == "tok-abc-123"
|
|
assert r.headers["content-type"].startswith("text/plain")
|
|
|
|
|
|
async def test_a_BROWSER_origin_is_accepted(clients):
|
|
"""`"*"` is not a wildcard in this SDK — origins are compared literally, and only a `:*` port
|
|
suffix is special. Setting `allowed_origins=["*"]` therefore allowed exactly one origin, the
|
|
literal string "*", and refused every browser with "Invalid Origin header".
|
|
|
|
Nothing caught it: the suite and every CLI client send NO Origin header, so the check never ran
|
|
until a real web page called /mcp/. This test sends one deliberately."""
|
|
from treg.config import get_settings
|
|
|
|
origin = get_settings().public_url.rstrip("/")
|
|
token = (await clients.post("/users", json={"email": "browser@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "browser", "version": "1"}}},
|
|
headers={**MCP_HEADERS, "Origin": origin, "Authorization": f"Bearer {token}"})
|
|
assert r.status_code == 200, f"a browser at our own origin must be served: {r.text[:120]}"
|
|
|
|
|
|
async def test_an_UNKNOWN_origin_is_still_refused(clients):
|
|
"""The protection has to remain real — the fix widens the list, it does not remove the check."""
|
|
token = (await clients.post("/users", json={"email": "origin@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
|
|
headers={**MCP_HEADERS, "Origin": "https://attacker.example",
|
|
"Authorization": f"Bearer {token}"})
|
|
assert r.status_code == 403, r.text # a credential passes auth; the ORIGIN guard still refuses
|
|
|
|
|
|
async def test_every_tool_declares_what_it_RETURNS(clients):
|
|
"""ChatGPT's connector review flags a tool with no output schema, and a model that has to guess
|
|
at field names guesses. Each tool now declares its shape."""
|
|
from treg.mcp import mcp as server
|
|
|
|
for t in await server.list_tools():
|
|
assert t.output_schema, f"{t.name} has no output schema"
|
|
assert t.output_schema.get("properties"), f"{t.name}'s schema is empty"
|
|
|
|
|
|
async def test_the_output_schema_does_not_BREAK_the_error_paths(clients):
|
|
"""The load-bearing detail. A strict schema is validated on the way out, so `{"error": "not
|
|
authenticated"}` would RAISE instead of returning — turning a refusal written to tell an agent
|
|
how to recover into an opaque tool failure.
|
|
|
|
Every field is optional so both shapes pass. A schema is a hint to the model, not a gate on our
|
|
own error handling."""
|
|
from treg.mcp import mcp as server
|
|
|
|
for t in await server.list_tools():
|
|
assert not t.output_schema.get("required"), (
|
|
f"{t.name} has required output fields — the first error response will raise")
|
|
|
|
# and prove it end to end, not just in the schema. A BAD token rather than none: a missing
|
|
# credential is now answered at the transport with a 401, so it never reaches the tool — and it
|
|
# is the tool's own error shape that has to survive the schema.
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "balance", {}, token="not-a-real-token")
|
|
assert out.get("error"), out
|
|
assert "hint" in out or "detail" in out, "the recovery instruction must survive the schema"
|
|
|
|
|
|
async def test_the_schema_tolerates_NULLS_not_just_missing_keys(clients):
|
|
"""`total=False` says a key may be ABSENT; it does not say the value may be null, and nulls reach
|
|
the client from two directions. Real rows carry them — a registered tool with no description, an
|
|
endpoint with no published price. And the SDK serializes every response through the
|
|
TypedDict-derived model, which fills ABSENT keys in as `null` in structuredContent — so a
|
|
response that never mentions `next` still ships `"next": null`, and a strict client validating
|
|
against a `type: string` schema refuses the whole answer with -32602 (issue #93, and a second
|
|
independent report the same day).
|
|
|
|
So EVERY field of EVERY tool must allow null, not just the ones known to carry data nulls.
|
|
Asserted on the schema so the next field added is held to the same rule."""
|
|
from treg.mcp import mcp as server
|
|
|
|
for t in await server.list_tools():
|
|
schemas = [t.output_schema] + list(t.output_schema.get("$defs", {}).values())
|
|
for schema in schemas:
|
|
for field, spec in schema.get("properties", {}).items():
|
|
# `Any` renders as an unconstrained schema (no type at all) — that accepts null.
|
|
unconstrained = "type" not in spec and "anyOf" not in spec
|
|
allows_null = unconstrained or "null" in str(spec)
|
|
assert allows_null, (f"{t.name}: {schema.get('title')}.{field} does not allow null — "
|
|
f"the SDK fills absent keys in as null, so a strict client "
|
|
f"will refuse the whole response")
|
|
|
|
|
|
async def test_structured_content_VALIDATES_against_the_advertised_schema(clients):
|
|
"""End to end, as the two field reports arrived: a strict client validates structuredContent
|
|
against the outputSchema from tools/list and refuses the response on any mismatch. The schema
|
|
test above checks what we promise; this checks what we actually ship — with jsonschema playing
|
|
the strict client, so a serialization change in the SDK cannot regress this silently."""
|
|
import jsonschema
|
|
|
|
from treg.mcp import mcp as server
|
|
|
|
schemas = {t.name: t.output_schema for t in await server.list_tools()}
|
|
token = (await clients.post("/users", json={"email": "strict@superdesign.dev"})).json()["token"]
|
|
|
|
async with mcp_session(clients) as c:
|
|
# The exact failing calls from the reports: a search (next set, hint absent) and the two
|
|
# tools whose error shape carries teams/hint — plus a catalog_get error path.
|
|
for tool, args in [("catalog_search", {"query": "backlinks"}),
|
|
("catalog_get", {"endpoint_id": "no.such.endpoint"}),
|
|
# its example_response is an ARRAY of records, which a dict-typed field
|
|
# refused server-side — the third null/shape mismatch found in the wild
|
|
("catalog_get", {"endpoint_id": "brightdata.linkedin.user.profile"}),
|
|
("balance", {}),
|
|
("my_tools", {})]:
|
|
await _rpc(c, "initialize", {
|
|
"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "strict", "version": "1"}}, token)
|
|
r = await _rpc(c, "tools/call", {"name": tool, "arguments": args}, token)
|
|
structured = (r.json().get("result") or {}).get("structuredContent")
|
|
assert structured is not None, f"{tool} returned no structuredContent: {r.text[:200]}"
|
|
jsonschema.validate(structured, schemas[tool]) # raises on any mismatch
|
|
|
|
|
|
async def test_large_mcp_responses_are_gzipped_AT_THE_ORIGIN(clients):
|
|
"""The fix that actually works for edge re-compression (issue #100). Render's edge ignored
|
|
`no-transform` and kept Brotli-compressing large responses — which a real client stack
|
|
(httpx + brotlicffi) fails to decode and then hangs on. An edge only compresses what arrives
|
|
UNCOMPRESSED, so the origin gzips its own responses: the edge passes them through, and gzip
|
|
decodes via zlib everywhere.
|
|
|
|
Asserted the way the field report arrived: a large catalog_get with `Accept-Encoding: br, gzip`
|
|
(what httpx sends) must come back gzip — OUR encoding — not unencoded (which the edge would
|
|
Brotli) and not br."""
|
|
import json as _json
|
|
|
|
token = (await clients.post("/users", json={"email": "gz@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
await _rpc(c, "initialize", {
|
|
"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "gz", "version": "1"}}, token)
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
"params": {"name": "catalog_get",
|
|
"arguments": {"endpoint_id": "brightdata.linkedin.user.profile"}}},
|
|
headers={**MCP_HEADERS, "Authorization": f"Bearer {token}",
|
|
"Accept-Encoding": "br, gzip"})
|
|
assert r.status_code == 200
|
|
assert r.headers.get("content-encoding") == "gzip", dict(r.headers)
|
|
# httpx transparently gunzips `.content` — that it parses is the end-to-end decode proof,
|
|
# through the exact client library the field reports used.
|
|
body = _json.loads(r.content)
|
|
assert body.get("result"), body
|
|
|
|
# And a client that does NOT accept gzip gets identity from us — GZipMiddleware respects
|
|
# Accept-Encoding rather than forcing an encoding the client cannot decode.
|
|
async with mcp_session(clients) as c:
|
|
await _rpc(c, "initialize", {
|
|
"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "gz2", "version": "1"}}, token)
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
"params": {"name": "catalog_get",
|
|
"arguments": {"endpoint_id": "brightdata.linkedin.user.profile"}}},
|
|
headers={**MCP_HEADERS, "Authorization": f"Bearer {token}",
|
|
"Accept-Encoding": "identity"})
|
|
assert r.status_code == 200
|
|
assert "content-encoding" not in r.headers, dict(r.headers)
|
|
|
|
|
|
async def test_mcp_responses_forbid_edge_TRANSFORMS(clients):
|
|
"""Production sits behind Render's Cloudflare edge, which Brotli-compresses responses unless the
|
|
origin says not to. At least one real client stack (httpx + brotlicffi, issue #93) dies decoding
|
|
large compressed bodies and then hangs to its own timeout. `Cache-Control: no-transform` is the
|
|
origin's standard "do not re-encode" (RFC 9111), and Cloudflare honours it; `no-store` rides
|
|
along because these responses are per-caller and priced.
|
|
|
|
Asserted on both an authenticated answer and the 401 challenge — the header wrapper is outermost
|
|
precisely so the challenge path carries it too."""
|
|
token = (await clients.post("/users", json={"email": "edge@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
challenged = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, headers=MCP_HEADERS)
|
|
answered = await _rpc(c, "initialize", {
|
|
"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "edge", "version": "1"}}, token)
|
|
assert challenged.status_code == 401
|
|
for r in (challenged, answered):
|
|
assert r.headers.get("cache-control") == "no-store, no-transform", dict(r.headers)
|
|
|
|
|
|
# ---- refusing in the right SHAPE, not just refusing ------------------------------------------
|
|
|
|
@pytest.mark.parametrize("tool", ["call", "balance", "my_tools", "catalog_search", "catalog_get"])
|
|
async def test_a_protected_tool_answers_401_with_WWW_Authenticate(clients, tool):
|
|
"""The spec has a protected resource reply 401 with `WWW-Authenticate: Bearer
|
|
resource_metadata="…"`, because that header is how a client DISCOVERS it must authenticate and
|
|
where to start. A friendly sentence inside a 200 tells a human what happened and tells a program
|
|
nothing.
|
|
|
|
This passed unnoticed because ChatGPT authenticates up front. A client that connects first and
|
|
discovers auth lazily — which the spec allows — would read 200 as success."""
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": {"name": tool, "arguments": {}}}, headers=MCP_HEADERS)
|
|
assert r.status_code == 401, r.text
|
|
challenge = r.headers.get("www-authenticate", "")
|
|
assert challenge.startswith("Bearer "), challenge
|
|
assert "resource_metadata=" in challenge
|
|
assert "/.well-known/oauth-protected-resource" in challenge
|
|
assert r.json()["resource_metadata"].endswith("/.well-known/oauth-protected-resource")
|
|
|
|
|
|
@pytest.mark.parametrize("tool,args", [("catalog_search", {"query": "backlinks"}),
|
|
("catalog_get", {"endpoint_id": "hunter.people.email.find"})])
|
|
async def test_EVERY_tool_needs_a_credential_including_the_catalog(clients, tool, args):
|
|
"""One rule instead of two. An earlier version left the catalog tools open so a client could
|
|
browse before signing up, which made the contract "some tools need auth, some do not" — something
|
|
each client has to learn by trying.
|
|
|
|
This is about a predictable contract, not about hiding the catalog: /catalog/search is still
|
|
public on the WEBSITE, which the landing page and `treg catalog search` both rely on."""
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": {"name": tool, "arguments": args}}, headers=MCP_HEADERS)
|
|
assert r.status_code == 401, r.text
|
|
assert "resource_metadata" in r.headers.get("www-authenticate", "")
|
|
|
|
|
|
async def test_EAGER_auth_challenges_initialize_itself(clients):
|
|
"""Eager, not lazy. Every treg tool needs auth, so there is nothing to browse anonymously — and
|
|
the spec's canonical flow challenges the client's FIRST request (`initialize`) so OAuth runs
|
|
before the session proceeds. Leaving it open was why a client showed "Connected" and never
|
|
prompted. So `initialize` and `tools/list` without a credential are 401 + WWW-Authenticate, same
|
|
as a tool call."""
|
|
async with mcp_session(clients) as c:
|
|
for method in ("initialize", "tools/list"):
|
|
params = {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "t", "version": "1"}} if method == "initialize" else None
|
|
body = {"jsonrpc": "2.0", "id": 1, "method": method}
|
|
if params:
|
|
body["params"] = params
|
|
r = await c.post("http://localhost/mcp/", json=body, headers=MCP_HEADERS)
|
|
assert r.status_code == 401, f"{method}: {r.status_code}"
|
|
challenge = r.headers.get("www-authenticate", "")
|
|
assert "resource_metadata=" in challenge and 'scope="' in challenge, challenge
|
|
|
|
|
|
async def test_a_notification_and_ping_pass_without_a_token(clients):
|
|
"""Not everything is challenged: a JSON-RPC notification carries no id and expects no response,
|
|
and `ping` is the liveness check — 401ing either would be a challenge nobody can act on. Only
|
|
id-bearing requests (initialize, tools/*, …) need a credential."""
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", headers=MCP_HEADERS,
|
|
json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
assert r.status_code != 401, r.text
|
|
r = await c.post("http://localhost/mcp/", headers=MCP_HEADERS,
|
|
json={"jsonrpc": "2.0", "id": 1, "method": "ping"})
|
|
assert r.status_code != 401, r.text
|
|
|
|
|
|
async def test_a_BAD_token_is_the_tool_s_business_not_the_transport_s(clients):
|
|
"""The challenge fires only when there is NO credential. Deciding whether a token is valid needs
|
|
the database, and doing that in transport middleware would put a second authentication
|
|
implementation in front of the first."""
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": {"name": "balance", "arguments": {}}},
|
|
headers={**MCP_HEADERS, "Authorization": "Bearer not-a-real-token"})
|
|
assert r.status_code == 200
|
|
assert "error" in json.loads(r.json()["result"]["content"][0]["text"])
|
|
|
|
|
|
async def test_call_accepts_an_ARRAY_body(clients):
|
|
"""DataForSEO — the largest provider in the catalog at 217 endpoints — takes an ARRAY of task
|
|
objects on every one of its `live` POST routes. A dict-only `params` made all of them uncallable
|
|
with a pydantic type error, which reads as "you passed it wrong" rather than "this tool cannot
|
|
express that".
|
|
|
|
Found by trying a real call for the demo, not by reading the signature."""
|
|
token = (await clients.post("/users", json={"email": "arraybody@superdesign.dev"})).json()["token"]
|
|
# register the tool AS THAT TOKEN's org — a tool created in another org is correctly invisible
|
|
prev = clients.headers.get("X-Treg-Token")
|
|
clients.headers["X-Treg-Token"] = token
|
|
made = await clients.post("/tools", json={"name": "echo", "base_url": "http://upstream"})
|
|
if prev:
|
|
clients.headers["X-Treg-Token"] = prev
|
|
assert made.status_code == 200, made.text
|
|
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "call", {
|
|
"endpoint_id": "echo/anything", "method": "POST",
|
|
"params": [{"keyword": "payment api", "depth": 10}]}, token=token)
|
|
assert out.get("status") == 200, out
|
|
assert "validation error" not in json.dumps(out)
|
|
|
|
|
|
async def test_a_list_is_refused_for_a_GET_with_a_clear_reason(clients):
|
|
"""A query string is key/value pairs, so a list has no meaning there. Saying so beats a pydantic
|
|
trace that blames the caller for the tool's own limitation."""
|
|
token = (await clients.post("/users", json={"email": "listget@superdesign.dev"})).json()["token"]
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "call", {
|
|
"endpoint_id": "hunter.people.email.find", "params": [{"x": 1}]}, token=token)
|
|
assert "must be an object, not a list" in json.dumps(out), out
|
|
|
|
|
|
def test_a_relayed_402_carries_NO_link_out(clients):
|
|
"""ChatGPT's submission form asks whether a plugin "links or directs users out of ChatGPT to make
|
|
purchases", and only PHYSICAL goods can be supported. treg sells prepaid API credit — a digital
|
|
good — so a top-up link made the honest answer a yes in the one category they cannot support.
|
|
|
|
Asserted on a REAL 402 body, not on the source. My first version checked the module's text,
|
|
passed, and shipped a production response that still contained the link: the body nests under
|
|
`detail` and repeats the URL inside a prose `message`. Checking the code instead of the response
|
|
is precisely the failure this codebase keeps catching, and I wrote one.
|
|
"""
|
|
from treg.mcp import _without_purchase_pointers
|
|
|
|
real = {"detail": {
|
|
"error": "insufficient_balance",
|
|
"message": ("akta.companies.enrich would cost ~$0.875 on treg's akta key and this team's "
|
|
"balance is $0.5765.\n add funds: https://treg.to/app#billing"
|
|
"\n or use your own key: treg connections connect --provider akta"),
|
|
"balance_micro": 576500, "estimated_cost_micro": 875000,
|
|
"topup_url": "/app#billing", "provider": "akta"}}
|
|
blob = json.dumps(_without_purchase_pointers(real))
|
|
assert "http://" not in blob and "https://" not in blob, blob
|
|
assert "topup_url" not in blob, blob
|
|
|
|
|
|
def test_stripping_the_link_keeps_the_DIAGNOSIS(clients):
|
|
"""Removing the invitation to pay must not remove the explanation. An agent still needs to know
|
|
it ran out of money, how short it was, and that its own key is an alternative — otherwise the
|
|
refusal is indistinguishable from a broken endpoint."""
|
|
from treg.mcp import _without_purchase_pointers
|
|
|
|
real = {"detail": {
|
|
"error": "insufficient_balance",
|
|
"message": ("akta.companies.enrich would cost ~$0.875 and this team's balance is $0.5765."
|
|
"\n add funds: https://treg.to/app#billing"
|
|
"\n or use your own key: treg connections connect --provider akta"),
|
|
"balance_micro": 576500, "estimated_cost_micro": 875000}}
|
|
out = _without_purchase_pointers(real)
|
|
blob = json.dumps(out)
|
|
assert "insufficient_balance" in blob and "would cost" in blob
|
|
assert "connections connect" in blob, "the own-key alternative must survive"
|
|
assert out["detail"]["balance_micro"] == 576500
|
|
|
|
|
|
def test_the_strip_does_not_depend_on_which_host_we_run_as(clients):
|
|
"""A first version matched `public_url`, which differs per environment — so it stripped nothing
|
|
anywhere except production, and the local test passed while the deployed behaviour was wrong."""
|
|
from treg.mcp import _without_purchase_pointers
|
|
|
|
for host in ("https://treg.ngrok.app", "http://127.0.0.1:18790", "https://anything.example"):
|
|
out = _without_purchase_pointers({"m": f"pay here: {host}/app#billing"})
|
|
assert "http" not in json.dumps(out), host
|
|
|
|
|
|
async def test_a_402_THROUGH_THE_CALL_TOOL_carries_no_link(clients, monkeypatch):
|
|
"""The test the previous two should have been. They exercised `_without_purchase_pointers`
|
|
directly and passed even with the strip DELETED from `call` — the helper worked and nothing
|
|
connected it to the response a user sees.
|
|
|
|
This drives the real path: drain the balance, call a metered endpoint through the MCP tool, and
|
|
assert on what comes back.
|
|
"""
|
|
from sqlmodel import select
|
|
|
|
from treg.config import get_settings
|
|
from treg.db import session_maker
|
|
from treg.models import Org
|
|
|
|
monkeypatch.setenv("TREG_PLATFORM_KEY_TIKHUB", "PLATKEY")
|
|
monkeypatch.setenv("TREG_PLATFORM_PROVIDERS", "tikhub")
|
|
get_settings.cache_clear()
|
|
|
|
token = (await clients.post("/users", json={"email": "broke402@superdesign.dev"})).json()["token"]
|
|
prev = clients.headers.get("X-Treg-Token")
|
|
clients.headers["X-Treg-Token"] = token
|
|
org_id = (await clients.get("/orgs")).json()[0]["org_id"]
|
|
if prev:
|
|
clients.headers["X-Treg-Token"] = prev
|
|
async with session_maker() as db:
|
|
org = (await db.execute(select(Org).where(Org.id == org_id))).scalar_one()
|
|
org.balance_micro = 0 # spent out
|
|
db.add(org)
|
|
await db.commit()
|
|
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "call", {
|
|
"endpoint_id": "tikhub.tiktok.video.comments",
|
|
"params": {"aweme_id": "7"}}, token=token)
|
|
get_settings.cache_clear()
|
|
|
|
assert out.get("status") == 402, out
|
|
blob = json.dumps(out)
|
|
assert "http://" not in blob and "https://" not in blob, f"a link out survived: {blob}"
|
|
assert "topup_url" not in blob, blob
|
|
assert "not enough for this call" in blob, "the diagnosis must survive"
|
|
|
|
|
|
# ---- the expired-token challenge: what makes silent refresh possible -------------------------
|
|
|
|
async def test_an_EXPIRED_access_token_gets_401_invalid_token(clients):
|
|
"""RFC 6750 §3.1. A client whose access token expired presents it anyway; the resource answers
|
|
401 with `error="invalid_token"`, and THAT is the cue on which the client silently runs its
|
|
refresh grant. Our first challenge only covered the missing-header case, so an expired token
|
|
sailed through to the tool's friendly prose in a 200 — and Claude Code, told nothing, gave up
|
|
with "requires re-authorization" instead of refreshing."""
|
|
from treg import mcp_oauth
|
|
dead = mcp_oauth.make_access_token(user_id=7, org_id=3, audience=mcp_oauth.mcp_resource_url(),
|
|
scope="treg:call", ttl=-60) # born expired
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": {"name": "balance", "arguments": {}}},
|
|
headers={**MCP_HEADERS, "Authorization": f"Bearer {dead}"})
|
|
assert r.status_code == 401, r.text
|
|
challenge = r.headers.get("www-authenticate", "")
|
|
assert 'error="invalid_token"' in challenge, challenge
|
|
assert "resource_metadata=" in challenge
|
|
assert r.json()["error"] == "invalid_token"
|
|
|
|
|
|
async def test_a_wrong_audience_access_token_gets_401_invalid_token(clients):
|
|
"""A grant consented to a DIFFERENT resource must not be honoured here — and the refusal should
|
|
still be the machine-readable 401, not tool prose."""
|
|
from treg import mcp_oauth
|
|
other = mcp_oauth.make_access_token(user_id=7, org_id=3,
|
|
audience="https://evil.example/mcp/", scope="treg:call")
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": {"name": "balance", "arguments": {}}},
|
|
headers={**MCP_HEADERS, "Authorization": f"Bearer {other}"})
|
|
assert r.status_code == 401, r.text
|
|
assert 'error="invalid_token"' in r.headers.get("www-authenticate", "")
|
|
|
|
|
|
async def test_a_per_org_token_still_reaches_the_tool(clients):
|
|
"""Only bearers that CLAIM to be our OAuth access tokens are judged by the transport. A per-org
|
|
or identity token (the Codex env-var path) is the API's to validate downstream — the middleware
|
|
must pass it through, valid or not, rather than mislabel it `invalid_token` (its holder has no
|
|
refresh grant to run)."""
|
|
async with mcp_session(clients) as c:
|
|
r = await c.post("http://localhost/mcp/", json={
|
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
"params": {"name": "balance", "arguments": {}}},
|
|
headers={**MCP_HEADERS, "Authorization": "Bearer not-an-oauth-token-shape"})
|
|
assert r.status_code == 200, r.text # the tool answers (with its own error prose) — not a 401
|
|
|
|
|
|
async def test_call_passes_an_idempotency_key_through(clients):
|
|
"""The feature was built for agents and MCP is the agent path, so leaving `call` unable to send a
|
|
key made it unreachable from the surface it was for.
|
|
|
|
The key is the CALLER's, never derived from the request: two identical searches an hour apart are
|
|
new work, not a retry, and a server-invented key would hand back the stale answer — a 24-hour
|
|
cache wearing an idempotency badge."""
|
|
token = (await clients.post("/users", json={"email": "mcpidem@superdesign.dev"})).json()["token"]
|
|
prev = clients.headers.get("X-Treg-Token")
|
|
clients.headers["X-Treg-Token"] = token
|
|
made = await clients.post("/tools", json={"name": "echo", "base_url": "http://upstream"})
|
|
if prev:
|
|
clients.headers["X-Treg-Token"] = prev
|
|
assert made.status_code == 200, made.text
|
|
|
|
async with mcp_session(clients) as c:
|
|
out = await _call_tool(c, "call", {
|
|
"endpoint_id": "echo/anything", "method": "POST",
|
|
"params": {"x": 1}, "idempotency_key": "agent-retry-1"}, token=token)
|
|
assert out.get("status") == 200, out
|
|
|
|
|
|
async def test_the_key_is_optional_and_described_for_the_model(clients):
|
|
"""A model can only use it if the description says WHEN. The distinction that matters is retry
|
|
versus new work, because getting it wrong returns stale data rather than failing loudly."""
|
|
from treg.mcp import mcp as server
|
|
|
|
tool = [t for t in await server.list_tools() if t.name == "call"][0]
|
|
assert "idempotency_key" in tool.input_schema["properties"]
|
|
assert "idempotency_key" not in (tool.input_schema.get("required") or [])
|
|
desc = tool.description or ""
|
|
assert "repeating a call whose answer you did not receive" in desc
|
|
assert "new call, not a retry" in desc, "the model must be told when NOT to reuse a key"
|
|
|
|
|
|
async def test_the_same_key_through_MCP_bills_once(clients, monkeypatch):
|
|
"""End to end on the agent path: an agent retries with the same key, the provider is reached
|
|
once, and the balance moves once."""
|
|
from sqlmodel import select
|
|
|
|
from treg.config import get_settings
|
|
from treg.db import session_maker
|
|
from treg.models import Org
|
|
|
|
monkeypatch.setenv("TREG_PLATFORM_KEY_TIKHUB", "PLATKEY")
|
|
monkeypatch.setenv("TREG_PLATFORM_PROVIDERS", "tikhub")
|
|
get_settings.cache_clear()
|
|
|
|
token = (await clients.post("/users", json={"email": "mcponce@superdesign.dev"})).json()["token"]
|
|
prev = clients.headers.get("X-Treg-Token")
|
|
clients.headers["X-Treg-Token"] = token
|
|
org_id = (await clients.get("/orgs")).json()[0]["org_id"]
|
|
if prev:
|
|
clients.headers["X-Treg-Token"] = prev
|
|
|
|
async def balance() -> int:
|
|
async with session_maker() as db:
|
|
org = (await db.execute(select(Org).where(Org.id == org_id))).scalar_one()
|
|
return org.balance_micro or 0
|
|
|
|
args = {"endpoint_id": "tikhub.tiktok.video.comments", "params": {"aweme_id": "7"},
|
|
"idempotency_key": "one-piece-of-work"}
|
|
before = await balance()
|
|
async with mcp_session(clients) as c:
|
|
first = await _call_tool(c, "call", args, token=token)
|
|
assert first.get("status") == 200, first
|
|
charged = before - await balance()
|
|
assert charged > 0, "the first call must bill"
|
|
|
|
after_first = await balance()
|
|
async with mcp_session(clients) as c:
|
|
second = await _call_tool(c, "call", args, token=token)
|
|
get_settings.cache_clear()
|
|
|
|
assert second.get("status") == 200, second
|
|
assert second.get("replayed") is True, "the retry must be marked as a replay"
|
|
assert second.get("body") == first.get("body"), "and return the same answer"
|
|
assert await balance() == after_first, "and bill nothing"
|