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.
@@ -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()
@@ -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})
@@ -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")
@@ -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 [])]))