hyperroute-mcp 0.1.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.
- hyperroute_mcp/__init__.py +13 -0
- hyperroute_mcp/__main__.py +13 -0
- hyperroute_mcp/client.py +188 -0
- hyperroute_mcp/config.py +58 -0
- hyperroute_mcp/native.py +161 -0
- hyperroute_mcp/server.py +622 -0
- hyperroute_mcp/tokenstore.py +58 -0
- hyperroute_mcp-0.1.0.dist-info/METADATA +224 -0
- hyperroute_mcp-0.1.0.dist-info/RECORD +13 -0
- hyperroute_mcp-0.1.0.dist-info/WHEEL +5 -0
- hyperroute_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- hyperroute_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- hyperroute_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""HyperRoute MCP — a Model Context Protocol server that exposes the HyperRoute router
|
|
2
|
+
(register, login, recommend, describe, onboard, execute, report_outcome, HyperFeed, …) as MCP
|
|
3
|
+
tools, so a coordinator agent (Claude Code, Codex, Goose, Cursor, …) can drive the whole product
|
|
4
|
+
end-to-end in one conversation.
|
|
5
|
+
|
|
6
|
+
It talks to the router only over its public HTTP API and holds no product logic of its own.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from .server import mcp
|
|
12
|
+
|
|
13
|
+
__all__ = ["mcp", "__version__"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""`python -m hyperroute_mcp` — run the HyperRoute MCP server over stdio (the transport MCP
|
|
2
|
+
clients launch it with). Point it at a router with HYPERROUTE_BASE_URL; see config.py for the
|
|
3
|
+
full set of environment variables."""
|
|
4
|
+
|
|
5
|
+
from .server import mcp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
mcp.run() # stdio transport by default
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
main()
|
hyperroute_mcp/client.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Thin async HTTP client over the HyperRoute router's public API.
|
|
2
|
+
|
|
3
|
+
One method per endpoint this server touches. No product logic lives here — the client only
|
|
4
|
+
knows how to (a) carry the session bearer token, and (b) turn the router's responses into
|
|
5
|
+
plain dicts, surfacing its structured `detail` error bodies instead of raising, so the MCP
|
|
6
|
+
tools can hand a coordinator an actionable object (e.g. `needs_onboard` + signup instructions)
|
|
7
|
+
rather than a stack trace.
|
|
8
|
+
|
|
9
|
+
One endpoint answers in text rather than JSON: `recommend` with `format=text` returns the
|
|
10
|
+
compact tabular coordinator wire, so `_request(..., as_text=True)` returns a `str`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Session:
|
|
21
|
+
"""The MCP process's login state: the active bearer token and the resolved user_id.
|
|
22
|
+
|
|
23
|
+
Mutated in place by `register` / `login` so every later recommend/onboard/execute call
|
|
24
|
+
is authenticated as the same user for the life of the MCP connection.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
self.user_id: str | None = None
|
|
30
|
+
self.email: str | None = None
|
|
31
|
+
self.env_managed = False # token came from HYPERROUTE_API_KEY — don't read/write the cache
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def logged_in(self) -> bool:
|
|
35
|
+
return bool(self.api_key)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _unwrap(r: httpx.Response) -> Any:
|
|
39
|
+
"""Router response -> dict. Success bodies pass through; error bodies are normalized to
|
|
40
|
+
`{"_error": True, "_http_status": <code>, ...}` with the server's `detail` merged in."""
|
|
41
|
+
try:
|
|
42
|
+
body = r.json()
|
|
43
|
+
except ValueError:
|
|
44
|
+
body = {"raw": r.text}
|
|
45
|
+
if r.is_success:
|
|
46
|
+
return body
|
|
47
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
48
|
+
base = {"_error": True, "_http_status": r.status_code}
|
|
49
|
+
if isinstance(detail, dict):
|
|
50
|
+
return {**base, **detail}
|
|
51
|
+
if detail is not None:
|
|
52
|
+
return {**base, "message": detail}
|
|
53
|
+
return {**base, "body": body}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HyperRouteClient:
|
|
57
|
+
"""Stateless-per-call HTTP client bound to a mutable `Session` for auth."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, base_url: str, session: Session, timeout: float = 30.0) -> None:
|
|
60
|
+
self.base_url = base_url.rstrip("/")
|
|
61
|
+
self.session = session
|
|
62
|
+
self._timeout = timeout
|
|
63
|
+
|
|
64
|
+
def _headers(self, auth: bool) -> dict[str, str]:
|
|
65
|
+
h = {"content-type": "application/json"}
|
|
66
|
+
if auth and self.session.api_key:
|
|
67
|
+
h["authorization"] = f"Bearer {self.session.api_key}"
|
|
68
|
+
return h
|
|
69
|
+
|
|
70
|
+
async def _request(self, method: str, path: str, *, auth: bool = False,
|
|
71
|
+
json: dict | None = None, params: dict | None = None,
|
|
72
|
+
as_text: bool = False) -> Any:
|
|
73
|
+
if json is not None: # drop None fields so router defaults apply
|
|
74
|
+
json = {k: v for k, v in json.items() if v is not None}
|
|
75
|
+
if params is not None:
|
|
76
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
77
|
+
try:
|
|
78
|
+
async with httpx.AsyncClient(timeout=self._timeout) as c:
|
|
79
|
+
r = await c.request(method, f"{self.base_url}{path}",
|
|
80
|
+
headers=self._headers(auth), json=json, params=params)
|
|
81
|
+
except httpx.RequestError as e:
|
|
82
|
+
return {"_error": True, "message": f"could not reach router at {self.base_url}: {e}"}
|
|
83
|
+
if as_text and r.is_success:
|
|
84
|
+
return r.text
|
|
85
|
+
return _unwrap(r)
|
|
86
|
+
|
|
87
|
+
# -- surfaces -----------------------------------------------------------
|
|
88
|
+
async def health(self) -> Any:
|
|
89
|
+
return await self._request("GET", "/health")
|
|
90
|
+
|
|
91
|
+
async def register(self, email: str, password: str, display_name: str | None) -> Any:
|
|
92
|
+
return await self._request("POST", "/auth/register",
|
|
93
|
+
json={"email": email, "password": password,
|
|
94
|
+
"display_name": display_name})
|
|
95
|
+
|
|
96
|
+
async def verify(self, email: str, code: str) -> Any:
|
|
97
|
+
return await self._request("POST", "/auth/verify", json={"email": email, "code": code})
|
|
98
|
+
|
|
99
|
+
async def login(self, email: str, password: str) -> Any:
|
|
100
|
+
return await self._request("POST", "/auth/login", json={"email": email, "password": password})
|
|
101
|
+
|
|
102
|
+
async def request_login_code(self, email: str) -> Any:
|
|
103
|
+
return await self._request("POST", "/auth/login/request-code", json={"email": email})
|
|
104
|
+
|
|
105
|
+
async def login_with_code(self, email: str, code: str) -> Any:
|
|
106
|
+
return await self._request("POST", "/auth/login/code", json={"email": email, "code": code})
|
|
107
|
+
|
|
108
|
+
async def forgot_password(self, email: str) -> Any:
|
|
109
|
+
return await self._request("POST", "/auth/password/forgot", json={"email": email})
|
|
110
|
+
|
|
111
|
+
async def whoami(self) -> Any:
|
|
112
|
+
return await self._request("POST", "/auth/whoami", auth=True)
|
|
113
|
+
|
|
114
|
+
async def recommend_text(self, payload: dict) -> Any:
|
|
115
|
+
"""The coordinator wire: `detail=min` + `format=text` → compact tabular text (a `str`),
|
|
116
|
+
or the usual error dict. Depth is pulled per-tool afterwards via `describe`."""
|
|
117
|
+
return await self._request("POST", "/recommend", auth=True,
|
|
118
|
+
json={**payload, "detail": "min", "format": "text"},
|
|
119
|
+
as_text=True)
|
|
120
|
+
|
|
121
|
+
async def describe(self, payload: dict) -> Any:
|
|
122
|
+
return await self._request("POST", "/describe", auth=True, json=payload)
|
|
123
|
+
|
|
124
|
+
async def catalog(self) -> Any:
|
|
125
|
+
"""The router's tool catalog (id + kind + auth + capabilities). Public; used to resolve
|
|
126
|
+
which coordinator ids exist before declaring the native baseline."""
|
|
127
|
+
return await self._request("GET", "/console", params={"view": "tools", "format": "json"})
|
|
128
|
+
|
|
129
|
+
async def onboard_info(self, tool_id: str) -> Any:
|
|
130
|
+
return await self._request("GET", f"/onboard/{tool_id}", auth=True)
|
|
131
|
+
|
|
132
|
+
async def onboard(self, tool_id: str, api_key: str, label: str | None) -> Any:
|
|
133
|
+
return await self._request("POST", "/onboard", auth=True,
|
|
134
|
+
json={"tool_id": tool_id, "api_key": api_key, "label": label})
|
|
135
|
+
|
|
136
|
+
async def execute(self, tool_id: str, query: str) -> Any:
|
|
137
|
+
return await self._request("POST", "/execute", auth=True,
|
|
138
|
+
json={"tool_id": tool_id, "query": query})
|
|
139
|
+
|
|
140
|
+
async def read_result(self, ref: str, op: str, offset: int, limit: int,
|
|
141
|
+
path: list | None, query: str | None) -> Any:
|
|
142
|
+
return await self._request("POST", f"/result/{ref}/read", auth=True,
|
|
143
|
+
json={"op": op, "offset": offset, "limit": limit,
|
|
144
|
+
"path": path, "query": query})
|
|
145
|
+
|
|
146
|
+
async def list_credentials(self, user_id: str) -> Any:
|
|
147
|
+
return await self._request("GET", "/credentials", auth=True, params={"user_id": user_id})
|
|
148
|
+
|
|
149
|
+
async def report_outcome(self, payload: dict) -> Any:
|
|
150
|
+
return await self._request("POST", "/report_outcome", json=payload)
|
|
151
|
+
|
|
152
|
+
async def report_narrative(self, payload: dict) -> Any:
|
|
153
|
+
return await self._request("POST", "/report_narrative", json=payload)
|
|
154
|
+
|
|
155
|
+
async def console(self, view: str, user_id: str) -> Any:
|
|
156
|
+
return await self._request("GET", "/console", auth=True,
|
|
157
|
+
params={"view": view, "format": "json", "user_id": user_id})
|
|
158
|
+
|
|
159
|
+
async def facets_catalog(self) -> Any:
|
|
160
|
+
return await self._request("GET", "/facets/catalog")
|
|
161
|
+
|
|
162
|
+
async def get_preferences(self, project_id: str | None = None) -> Any:
|
|
163
|
+
return await self._request("GET", "/preferences", auth=True,
|
|
164
|
+
params={"project_id": project_id})
|
|
165
|
+
|
|
166
|
+
async def set_preferences(self, facets: dict, project_id: str | None = None) -> Any:
|
|
167
|
+
return await self._request("PUT", "/preferences", auth=True,
|
|
168
|
+
json={"facets": facets, "project_id": project_id})
|
|
169
|
+
|
|
170
|
+
# -- HyperFeed ----------------------------------------------------------
|
|
171
|
+
async def feed(self, stream: str | None = None, since: str | None = None,
|
|
172
|
+
limit: int = 50) -> Any:
|
|
173
|
+
return await self._request("GET", "/feed",
|
|
174
|
+
params={"stream": stream, "since": since, "limit": limit})
|
|
175
|
+
|
|
176
|
+
async def feed_streams(self) -> Any:
|
|
177
|
+
return await self._request("GET", "/feed/streams")
|
|
178
|
+
|
|
179
|
+
async def feed_digest(self, since: str | None = None, limit: int = 20) -> Any:
|
|
180
|
+
return await self._request("GET", "/feed/digest", auth=True,
|
|
181
|
+
params={"since": since, "limit": limit})
|
|
182
|
+
|
|
183
|
+
async def feed_subscribe(self, payload: dict) -> Any:
|
|
184
|
+
return await self._request("POST", "/feed/subscribe", auth=True, json=payload)
|
|
185
|
+
|
|
186
|
+
async def feed_react(self, item_id: str, action: str) -> Any:
|
|
187
|
+
return await self._request("POST", "/feed/react", auth=True,
|
|
188
|
+
json={"item_id": item_id, "action": action})
|
hyperroute_mcp/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Runtime configuration — everything is environment-driven, so the same package runs against
|
|
2
|
+
the hosted router, a self-hosted one, or a local dev instance with no code change.
|
|
3
|
+
|
|
4
|
+
HYPERROUTE_BASE_URL base URL of the router (default https://hyperroute.io)
|
|
5
|
+
HYPERROUTE_API_KEY optional hyr_… personal access token to start already logged in
|
|
6
|
+
HYPERROUTE_TIMEOUT per-request timeout in seconds (default 30)
|
|
7
|
+
HYPERROUTE_TOKEN_FILE where the login token is cached (default ~/.hyperroute/token.json)
|
|
8
|
+
|
|
9
|
+
HYPERROUTE_COORDINATOR which coordinator this server runs inside — "claude_code", "codex",
|
|
10
|
+
or "none" to disable the declaration entirely. Unset = auto-detect
|
|
11
|
+
from the MCP client's identity (see native.py).
|
|
12
|
+
HYPERROUTE_NATIVE_TOOLS explicit, comma-separated coordinator tool ids to declare instead of
|
|
13
|
+
auto-detecting (e.g. "claude_code_sonnet_quick" to pin one model).
|
|
14
|
+
HYPERROUTE_HELD comma-separated plan groups the user already pays for
|
|
15
|
+
(e.g. "anthropic_max_5x"), so those tools price at $0.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://hyperroute.io"
|
|
23
|
+
DEFAULT_TIMEOUT = 30.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def base_url() -> str:
|
|
27
|
+
return os.environ.get("HYPERROUTE_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def preset_api_key() -> str | None:
|
|
31
|
+
return os.environ.get("HYPERROUTE_API_KEY") or None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def timeout() -> float:
|
|
35
|
+
try:
|
|
36
|
+
return float(os.environ.get("HYPERROUTE_TIMEOUT", DEFAULT_TIMEOUT))
|
|
37
|
+
except ValueError:
|
|
38
|
+
return DEFAULT_TIMEOUT
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _csv(name: str) -> list[str]:
|
|
42
|
+
return [p.strip() for p in (os.environ.get(name) or "").split(",") if p.strip()]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def coordinator() -> str | None:
|
|
46
|
+
"""The coordinator product to declare, or None to auto-detect. "none"/"off" disables it."""
|
|
47
|
+
v = (os.environ.get("HYPERROUTE_COORDINATOR") or "").strip().lower()
|
|
48
|
+
return v or None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def native_tools() -> list[str]:
|
|
52
|
+
"""Explicit coordinator tool ids to declare, bypassing detection."""
|
|
53
|
+
return _csv("HYPERROUTE_NATIVE_TOOLS")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def held_plans() -> list[str]:
|
|
57
|
+
"""Plan groups the user holds — a held plan makes that tool free to them."""
|
|
58
|
+
return _csv("HYPERROUTE_HELD")
|
hyperroute_mcp/native.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Declaring the native baseline — the coordinator this server runs inside.
|
|
2
|
+
|
|
3
|
+
HyperRoute never assumes you have a coordinator. Its verdict for a task is one of:
|
|
4
|
+
|
|
5
|
+
* **interpose** — an external tool beats what you can already do, so route to it;
|
|
6
|
+
* **use_native** — nothing beats your own tools, so do it yourself.
|
|
7
|
+
|
|
8
|
+
That comparison needs a baseline, and the baseline is the set of coordinators that are
|
|
9
|
+
effectively free to the caller. An MCP server that does not say which coordinator it runs
|
|
10
|
+
inside gives the router no baseline at all, so an external tool wins *every* time — including
|
|
11
|
+
for tasks the coordinator does better itself. Declaring it is therefore not optional.
|
|
12
|
+
|
|
13
|
+
Two independent ways to be effectively free, both sent on the `recommend` call's `context`:
|
|
14
|
+
|
|
15
|
+
* `native_tools` — **self-loopback**: the caller IS this coordinator. Zero marginal cost.
|
|
16
|
+
* `entitlements.held` — a **subscription** the user already pays for (e.g. `anthropic_max_5x`),
|
|
17
|
+
which also prices that tool at $0.
|
|
18
|
+
|
|
19
|
+
Resolution order, first hit wins:
|
|
20
|
+
|
|
21
|
+
1. `HYPERROUTE_NATIVE_TOOLS` — explicit tool ids, e.g. to pin one model variant.
|
|
22
|
+
2. `HYPERROUTE_COORDINATOR` — a product name (`claude_code`, `codex`); `none`/`off` disables.
|
|
23
|
+
3. Auto-detect from the MCP client's own identity, sent on the protocol handshake.
|
|
24
|
+
|
|
25
|
+
A product name resolves to concrete tool ids against the router's live catalog (fetched once
|
|
26
|
+
per process, cached), so a new coordinator variant needs no release here. If detection finds
|
|
27
|
+
nothing the declaration is simply omitted — routing still works, it just has no baseline.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from . import config
|
|
33
|
+
|
|
34
|
+
# MCP client identity (as sent on the protocol handshake) -> the coordinator product it is.
|
|
35
|
+
# Matched as a prefix on the lowercased, punctuation-normalized client name, longest first, so
|
|
36
|
+
# "claude-code" wins over a bare "claude". Deliberately conservative: an unrecognized client
|
|
37
|
+
# declares nothing rather than claiming capability it does not have.
|
|
38
|
+
_CLIENT_PRODUCTS = {
|
|
39
|
+
"claude_code": "claude_code",
|
|
40
|
+
"codex": "codex",
|
|
41
|
+
"cursor": "cursor",
|
|
42
|
+
"goose": "goose",
|
|
43
|
+
"cline": "cline",
|
|
44
|
+
"opencode": "opencode",
|
|
45
|
+
"aider": "aider",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_DISABLED = {"none", "off", "no", "false", "0"}
|
|
49
|
+
|
|
50
|
+
# Resolved once per process: product name -> the coordinator tool ids the router models for it.
|
|
51
|
+
_catalog_cache: dict[str, list[str]] | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_client(name: str | None) -> str:
|
|
55
|
+
"""`Claude Code`, `claude-code`, `claude_code/1.2` -> `claude_code`."""
|
|
56
|
+
if not name:
|
|
57
|
+
return ""
|
|
58
|
+
out = []
|
|
59
|
+
for ch in name.strip().lower():
|
|
60
|
+
out.append(ch if ch.isalnum() else "_")
|
|
61
|
+
return "".join(out).strip("_")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def product_for_client(name: str | None) -> str | None:
|
|
65
|
+
"""Which coordinator product an MCP client is, or None when we don't recognize it."""
|
|
66
|
+
norm = normalize_client(name)
|
|
67
|
+
if not norm:
|
|
68
|
+
return None
|
|
69
|
+
for key in sorted(_CLIENT_PRODUCTS, key=len, reverse=True):
|
|
70
|
+
if norm == key or norm.startswith(key + "_"):
|
|
71
|
+
return _CLIENT_PRODUCTS[key]
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _match(tool_id: str, product: str) -> bool:
|
|
76
|
+
return tool_id == product or tool_id.startswith(product + "_")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def coordinator_ids(client, product: str) -> list[str]:
|
|
80
|
+
"""The router's tool ids for a coordinator product, read from its live catalog.
|
|
81
|
+
|
|
82
|
+
All of a product's variants are declared together: the coordinator can switch model or
|
|
83
|
+
depth mid-session, so the product — not one variant — is what the caller *is*. Pin a single
|
|
84
|
+
variant with `HYPERROUTE_NATIVE_TOOLS` when that matters.
|
|
85
|
+
"""
|
|
86
|
+
global _catalog_cache
|
|
87
|
+
if _catalog_cache is None:
|
|
88
|
+
cat = await client.catalog()
|
|
89
|
+
tools = cat.get("tools") if isinstance(cat, dict) else None
|
|
90
|
+
if not tools:
|
|
91
|
+
return [] # unreachable/empty catalog: declare nothing
|
|
92
|
+
by_product: dict[str, list[str]] = {}
|
|
93
|
+
for t in tools:
|
|
94
|
+
if t.get("kind") != "coordinator_agent":
|
|
95
|
+
continue
|
|
96
|
+
tid = t.get("id") or ""
|
|
97
|
+
for prod in set(_CLIENT_PRODUCTS.values()):
|
|
98
|
+
if _match(tid, prod):
|
|
99
|
+
by_product.setdefault(prod, []).append(tid)
|
|
100
|
+
_catalog_cache = by_product
|
|
101
|
+
return sorted(_catalog_cache.get(product, []))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def reset_cache() -> None:
|
|
105
|
+
"""Drop the cached catalog (tests; a router that swapped bundles mid-session)."""
|
|
106
|
+
global _catalog_cache
|
|
107
|
+
_catalog_cache = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def declared_context(client, client_name: str | None) -> dict:
|
|
111
|
+
"""The `context` fragment to merge into every route: what this caller already has.
|
|
112
|
+
|
|
113
|
+
Returns `{}` when nothing is known — no baseline, and the best external tool simply wins.
|
|
114
|
+
"""
|
|
115
|
+
setting = config.coordinator()
|
|
116
|
+
if setting in _DISABLED:
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
ids = config.native_tools()
|
|
120
|
+
if not ids:
|
|
121
|
+
product = setting or product_for_client(client_name)
|
|
122
|
+
if product:
|
|
123
|
+
ids = await coordinator_ids(client, product)
|
|
124
|
+
|
|
125
|
+
out: dict = {}
|
|
126
|
+
if ids:
|
|
127
|
+
out["native_tools"] = ids
|
|
128
|
+
held = config.held_plans()
|
|
129
|
+
if held:
|
|
130
|
+
out["entitlements"] = {"held": held}
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def merge_context(declared: dict, supplied: dict | None) -> dict | None:
|
|
135
|
+
"""Merge the declaration UNDER a caller-supplied `context`.
|
|
136
|
+
|
|
137
|
+
`native_tools` and `entitlements.held` are **unioned, never dropped** — what the caller is, and
|
|
138
|
+
what it holds, are structural facts, so a per-call context adds to them rather than replacing
|
|
139
|
+
them. (Silently losing the declaration on a call that happened to pass a context is the exact
|
|
140
|
+
failure this whole module exists to prevent.) Every other key the caller passes wins outright.
|
|
141
|
+
To route with no baseline at all, disable the declaration itself: `HYPERROUTE_COORDINATOR=none`.
|
|
142
|
+
"""
|
|
143
|
+
caller = dict(supplied or {})
|
|
144
|
+
if not declared and not caller:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
merged = {**caller}
|
|
148
|
+
native = _union(declared.get("native_tools"), caller.get("native_tools"))
|
|
149
|
+
if native:
|
|
150
|
+
merged["native_tools"] = native
|
|
151
|
+
|
|
152
|
+
caller_ent = dict(caller.get("entitlements") or {})
|
|
153
|
+
held = _union((declared.get("entitlements") or {}).get("held"), caller_ent.get("held"))
|
|
154
|
+
if held or caller_ent:
|
|
155
|
+
merged["entitlements"] = {**caller_ent, **({"held": held} if held else {})}
|
|
156
|
+
return merged or None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _union(*lists) -> list[str]:
|
|
160
|
+
"""Order-preserving union — declaration first, caller's additions after."""
|
|
161
|
+
return list(dict.fromkeys([x for lst in lists for x in (lst or [])]))
|