insightfactory-cli 1.0.3.dev20__tar.gz → 1.0.3.dev21__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/PKG-INFO +7 -1
  2. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/README.md +6 -0
  3. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/pyproject.toml +1 -1
  4. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/mcp.py +2 -1
  5. insightfactory_cli-1.0.3.dev21/src/if_cli/router/log.py +13 -0
  6. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/server.py +1 -5
  7. insightfactory_cli-1.0.3.dev21/src/if_cli/router/upstream.py +272 -0
  8. insightfactory_cli-1.0.3.dev21/tests/test_router_upstream.py +370 -0
  9. insightfactory_cli-1.0.3.dev20/src/if_cli/router/upstream.py +0 -107
  10. insightfactory_cli-1.0.3.dev20/tests/test_router_upstream.py +0 -80
  11. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.github/workflows/ci.yml +0 -0
  12. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.github/workflows/claude.yml +0 -0
  13. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.github/workflows/release.yml +0 -0
  14. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.gitignore +0 -0
  15. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.python-version +0 -0
  16. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/AGENTS.md +0 -0
  17. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/CLAUDE.md +0 -0
  18. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/LICENSE +0 -0
  19. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/__init__.py +0 -0
  20. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/__main__.py +0 -0
  21. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/assets/__init__.py +0 -0
  22. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
  23. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/cache.py +0 -0
  24. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/callback_page.py +0 -0
  25. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/cli.py +0 -0
  26. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/colour.py +0 -0
  27. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/__init__.py +0 -0
  28. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/api.py +0 -0
  29. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/config.py +0 -0
  30. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/login.py +0 -0
  31. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/logout.py +0 -0
  32. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/profiles.py +0 -0
  33. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/set_token.py +0 -0
  34. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/token.py +0 -0
  35. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/config.py +0 -0
  36. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/constants.py +0 -0
  37. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/http.py +0 -0
  38. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/main.py +0 -0
  39. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/oauth.py +0 -0
  40. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/__init__.py +0 -0
  41. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/catalog.py +0 -0
  42. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/policy.py +0 -0
  43. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/runtime.py +0 -0
  44. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/__init__.py +0 -0
  45. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/cache_writer.py +0 -0
  46. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/conftest.py +0 -0
  47. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/helpers.py +0 -0
  48. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/servers.py +0 -0
  49. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_api.py +0 -0
  50. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_api_command.py +0 -0
  51. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_cache.py +0 -0
  52. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_cli.py +0 -0
  53. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_config.py +0 -0
  54. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_config_command.py +0 -0
  55. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_login.py +0 -0
  56. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_mcp_command.py +0 -0
  57. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_oauth.py +0 -0
  58. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_oauth_flow.py +0 -0
  59. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_oauth_force_refresh.py +0 -0
  60. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_profiles.py +0 -0
  61. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_programmatic_api.py +0 -0
  62. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_catalog.py +0 -0
  63. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_policy.py +0 -0
  64. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_server.py +0 -0
  65. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_runtime.py +0 -0
  66. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_set_token.py +0 -0
  67. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_token.py +0 -0
  68. {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.0.3.dev20
3
+ Version: 1.0.3.dev21
4
4
  Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
5
  Project-URL: Homepage, https://insightfactory.ai
6
6
  Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
@@ -304,6 +304,12 @@ Passing dev-only writes to production means changing an argument value, not
304
304
  connecting to a different server, so keep `--writable` scoped to the
305
305
  environments meant to be written by hand.
306
306
 
307
+ A tool the factory will not run until someone finishes a step in a browser, such
308
+ as linking a personal Databricks identity, comes back as a failed call naming the
309
+ page to visit rather than as a prompt. The router never relays the factory's
310
+ request for input to the model, and it names a page only on that environment's
311
+ own origin, logging a `WARN` line on stderr when it drops one that is not.
312
+
307
313
  ## Development
308
314
 
309
315
  ```bash
@@ -280,6 +280,12 @@ Passing dev-only writes to production means changing an argument value, not
280
280
  connecting to a different server, so keep `--writable` scoped to the
281
281
  environments meant to be written by hand.
282
282
 
283
+ A tool the factory will not run until someone finishes a step in a browser, such
284
+ as linking a personal Databricks identity, comes back as a failed call naming the
285
+ page to visit rather than as a prompt. The router never relays the factory's
286
+ request for input to the model, and it names a page only on that environment's
287
+ own origin, logging a `WARN` line on stderr when it drops one that is not.
288
+
283
289
  ## Development
284
290
 
285
291
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.0.3.dev20"
3
+ version = "1.0.3.dev21"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -132,7 +132,8 @@ def mcp_command(argv: list[str]) -> None:
132
132
  # import bug inside if_cli.router propagates instead of reading as a missing extra.
133
133
  if importlib.util.find_spec("mcp") is None:
134
134
  die(MISSING_MCP_EXTRA)
135
- from if_cli.router.server import build_router, log, run_server
135
+ from if_cli.router.log import log
136
+ from if_cli.router.server import build_router, run_server
136
137
 
137
138
  timeout = parse_timeout_seconds(values["timeout"], "--timeout")
138
139
  extra_read_only = frozenset(values["allow-tool"] or [])
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+
6
+ def log(message: str) -> None:
7
+ """Write one diagnostic line to stderr.
8
+
9
+ The router speaks MCP over stdout, so stderr is the only channel that can
10
+ carry anything to the operator. This lives in its own module because both
11
+ `server` and `upstream` write to it and `server` imports `upstream`.
12
+ """
13
+ sys.stderr.write(f"[if-cli mcp] {message}\n")
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- import sys
5
4
  from dataclasses import dataclass
6
5
  from importlib.metadata import version
7
6
  from typing import Any
@@ -15,6 +14,7 @@ from mcp.server.stdio import stdio_server
15
14
  from if_cli.config import Profile
16
15
  from if_cli.router import catalog, policy
17
16
  from if_cli.router.catalog import ENV_ARG, EnvironmentSpec
17
+ from if_cli.router.log import log
18
18
  from if_cli.router.upstream import Upstream
19
19
  from if_cli.runtime import CliError
20
20
 
@@ -24,10 +24,6 @@ SERVER_VERSION = version("insightfactory-cli")
24
24
  DEFAULT_CATALOG_RETRY_WINDOW_S = 5.0
25
25
 
26
26
 
27
- def log(message: str) -> None:
28
- sys.stderr.write(f"[if-cli mcp] {message}\n")
29
-
30
-
31
27
  def _catalog_retry_window_seconds() -> float:
32
28
  """Read the catalogue-rebuild retry window, in milliseconds, from the environment.
33
29
 
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any
5
+
6
+ import anyio
7
+ import httpx2
8
+ import mcp_types as types
9
+ from mcp.client import Client
10
+ from mcp.client.streamable_http import streamable_http_client
11
+
12
+ from if_cli.config import Profile
13
+ from if_cli.http import parse_url, url_origin
14
+ from if_cli.oauth import get_valid_token
15
+ from if_cli.router.log import log
16
+
17
+ if sys.version_info >= (3, 11):
18
+ _BaseExceptionGroup = BaseExceptionGroup # noqa: F821 - builtin from 3.11 on, guarded above
19
+ else: # pragma: no cover - exercised only on Python < 3.11
20
+ # anyio pulls in this backport for exactly that case, so it is always
21
+ # importable here when the builtin is not.
22
+ from exceptiongroup import BaseExceptionGroup as _BaseExceptionGroup # ty: ignore[unresolved-import]
23
+
24
+
25
+ def _leaf_exception(error: BaseException) -> BaseException:
26
+ """Unwrap nested single-member `BaseExceptionGroup`s down to their real cause.
27
+
28
+ anyio/httpx2 run request handling in task groups, so a failure inside
29
+ `ProfileBearerAuth` (e.g. the `CliError` login hint from `get_valid_token`)
30
+ reaches the caller wrapped in one or more `BaseExceptionGroup`s, and a plain
31
+ `str(error)` on those prints "unhandled errors in a TaskGroup" instead of the
32
+ real message. A group with several leaves has no single real cause, so those
33
+ are joined instead of picked from.
34
+ """
35
+ if isinstance(error, _BaseExceptionGroup):
36
+ leaves = [_leaf_exception(sub) for sub in error.exceptions]
37
+ if len(leaves) == 1:
38
+ return leaves[0]
39
+ return RuntimeError("; ".join(str(leaf) for leaf in leaves))
40
+ return error
41
+
42
+
43
+ class ProfileBearerAuth(httpx2.Auth):
44
+ """Injects a fresh `if-cli` bearer token per request; re-mints once on a 401.
45
+
46
+ Never sends a profile's token to any origin but its own factory host —
47
+ the rule from AGENTS.md, made mechanical.
48
+ """
49
+
50
+ def __init__(self, profile: Profile) -> None:
51
+ self._profile = profile
52
+
53
+ async def async_auth_flow(self, request: httpx2.Request): # noqa: ANN201 - httpx2.Auth's own generator protocol
54
+ origin = url_origin(parse_url(str(request.url)))
55
+ if origin != self._profile["host"]:
56
+ raise RuntimeError(
57
+ f"refusing to send the '{self._profile['name']}' profile's bearer token to {origin}; "
58
+ f"its factory origin is {self._profile['host']}"
59
+ )
60
+ token = await anyio.to_thread.run_sync(get_valid_token, self._profile)
61
+ request.headers["Authorization"] = f"Bearer {token}"
62
+ response = yield request
63
+ if response.status_code == 401:
64
+ token = await anyio.to_thread.run_sync(lambda: get_valid_token(self._profile, force_refresh=True))
65
+ request.headers["Authorization"] = f"Bearer {token}"
66
+ yield request
67
+
68
+
69
+ def _result_texts(result: Any) -> list[str]:
70
+ """The text of a result's content blocks, dropping any block that is not text.
71
+
72
+ Only the older-revision path needs this, and there the result is the tool's
73
+ own refusal, which is text.
74
+ """
75
+ return [block.text for block in getattr(result, "content", []) if isinstance(block, types.TextContent)]
76
+
77
+
78
+ def _connect_url(params: types.ElicitRequestURLParams, origin: str) -> str | None:
79
+ """The elicited URL, if it is on this environment's own origin.
80
+
81
+ What the check buys is narrow and worth stating plainly: the router will not
82
+ itself render a link to a host that is not this environment's. It cannot stop
83
+ a factory naming a host in the prose of a message, here or in ordinary tool
84
+ result text, which passes through verbatim either way. Same rule as the
85
+ bearer token in `ProfileBearerAuth`, applied in the other direction.
86
+
87
+ A rejected URL is reported on stderr. It means the factory named a page on a
88
+ host that is not its own, which the operator wants to hear about, and which
89
+ the tool result cannot distinguish from an elicitation in a mode the router
90
+ does not answer.
91
+ """
92
+ try:
93
+ # `urlparse` defers port validation to `.port`, which `url_origin` reads,
94
+ # so a malformed port raises here rather than in `parse_url`.
95
+ if url_origin(parse_url(params.url)) == origin:
96
+ return params.url
97
+ except ValueError:
98
+ pass
99
+ log(f"WARN dropped a connect URL that is not on {origin}: {params.url!r}")
100
+ return None
101
+
102
+
103
+ class ConnectUrlRecorder:
104
+ """Declines every elicitation, keeping the first same-origin URL it saw.
105
+
106
+ Two jobs. Constructing this and handing it to `Client` is what advertises
107
+ `elicitation.url`: the SDK derives the capability from the callback being
108
+ non-default, and the factory only offers its Databricks connect page to a
109
+ client that advertised the URL mode, so the capability has to be on for the
110
+ page to be named at all.
111
+
112
+ The recording is for older servers only. On 2026-07-28 elicitations arrive
113
+ inside an `InputRequiredResult`, which `Upstream.call_tool` takes in hand
114
+ rather than letting the SDK dispatch the embedded requests here, so nothing
115
+ reaches this callback. A server negotiating an earlier revision sends
116
+ `elicitation/create` as its own request, which does land here, and without
117
+ the recording its eventual result would carry the refusal with no page named.
118
+
119
+ Declining is the right answer either way. Answering an elicitation means
120
+ relaying it to the downstream client, and connecting production Databricks
121
+ under a personal identity should be a deliberate trip to a page rather than a
122
+ link offered mid tool call.
123
+ """
124
+
125
+ def __init__(self, origin: str) -> None:
126
+ self._origin = origin
127
+ self.declined_url_request = False
128
+ self.url: str | None = None
129
+
130
+ async def __call__(self, _context: Any, params: Any) -> types.ElicitResult:
131
+ if isinstance(params, types.ElicitRequestURLParams):
132
+ # What makes the call a refusal is the decline, not which host the URL
133
+ # named. Recording the two separately keeps the origin check out of the
134
+ # question of whether the tool ran.
135
+ self.declined_url_request = True
136
+ if self.url is None:
137
+ self.url = _connect_url(params, self._origin)
138
+ return types.ElicitResult(action="decline")
139
+
140
+
141
+ def _relayable_requests(result: types.InputRequiredResult, origin: str) -> tuple[list[str], str | None]:
142
+ """The messages this router will repeat, and the first connect URL it will name.
143
+
144
+ A URL-mode request whose URL fails the origin check is dropped whole, its
145
+ message included: the router rejected the one claim in that request it could
146
+ check, so it repeats none of it. Requests in any other mode make no such
147
+ claim and keep their message, which is all there is to pass on.
148
+
149
+ `result.request_state` is dropped on purpose. It is the token for retrying the
150
+ same call once the input is supplied, and the router never retries: the connect
151
+ flow is a trip to a page in a browser, well outside the lifetime of this stdio
152
+ process and of the factory's own state TTL.
153
+ """
154
+ messages: list[str] = []
155
+ url: str | None = None
156
+ for request in (result.input_requests or {}).values():
157
+ params = getattr(request, "params", None)
158
+ if isinstance(params, types.ElicitRequestURLParams):
159
+ found = _connect_url(params, origin)
160
+ if found is None:
161
+ continue
162
+ if url is None:
163
+ url = found
164
+ message = getattr(params, "message", None)
165
+ if isinstance(message, str) and message:
166
+ messages.append(message)
167
+ return messages, url
168
+
169
+
170
+ class Upstream:
171
+ """One factory environment's `/mcp` endpoint, reached with its own `if-cli` profile.
172
+
173
+ The factory's streamable-HTTP transport is stateless, so every call opens
174
+ its own client session (connect, initialize, call, close) instead of
175
+ holding a long-lived connection open for the process lifetime. That keeps
176
+ this class small and avoids babysitting three persistent connections.
177
+ """
178
+
179
+ def __init__(self, code: str, profile: Profile, *, timeout: float) -> None:
180
+ self.code = code
181
+ self.profile = profile
182
+ self.timeout = timeout
183
+
184
+ async def _run(self, action, *, elicitation_callback=None):
185
+ url = f"{self.profile['host']}/mcp"
186
+ try:
187
+ async with httpx2.AsyncClient(auth=ProfileBearerAuth(self.profile), timeout=self.timeout) as http_client:
188
+ async with Client(
189
+ streamable_http_client(url, http_client=http_client),
190
+ elicitation_callback=elicitation_callback,
191
+ ) as client:
192
+ return await action(client)
193
+ except _BaseExceptionGroup as error:
194
+ raise _leaf_exception(error) from error
195
+
196
+ async def list_tools(self) -> list[dict[str, Any]]:
197
+ async def collect(client: Client) -> list[dict[str, Any]]:
198
+ collected: list[dict[str, Any]] = []
199
+ cursor: str | None = None
200
+ while True:
201
+ result = await client.list_tools(cursor=cursor)
202
+ collected.extend(
203
+ tool.model_dump(by_alias=True, mode="json", exclude_unset=True) for tool in result.tools
204
+ )
205
+ if not result.next_cursor:
206
+ return collected
207
+ cursor = result.next_cursor
208
+
209
+ return await self._run(collect)
210
+
211
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> types.CallToolResult:
212
+ async def call(client: Client):
213
+ # `allow_input_required` hands back the factory's `InputRequiredResult`
214
+ # instead of letting the SDK answer the embedded requests and retry.
215
+ # The router has to drive this itself: the factory re-elicits on every
216
+ # retry that is still unconnected, so the SDK's automatic loop spins to
217
+ # `input_required_max_rounds` and raises before any result comes back.
218
+ return await client.session.call_tool(name, arguments, allow_input_required=True)
219
+
220
+ recorder = ConnectUrlRecorder(self.profile["host"])
221
+ result = await self._run(call, elicitation_callback=recorder)
222
+ if isinstance(result, types.InputRequiredResult):
223
+ lines, url = _relayable_requests(result, self.profile["host"])
224
+ return self._connect_refusal(lines, url)
225
+ if recorder.declined_url_request:
226
+ # The older-revision path. The server elicited in band and carried on
227
+ # once the router declined, so the connect page is in the recorder
228
+ # rather than in the result, and the result is whatever the tool says
229
+ # for an identity it never got. Anything it carried beyond text goes:
230
+ # the router declined, so the call did not run and there is no result
231
+ # to preserve.
232
+ return self._connect_refusal(_result_texts(result), recorder.url)
233
+ return result
234
+
235
+ def _connect_refusal(self, lines: list[str], url: str | None) -> types.CallToolResult:
236
+ """Turn the factory's request for input into a refusal naming the page to visit.
237
+
238
+ Some factory tools (`run_sql_query` and the other Databricks-backed ones)
239
+ run under the caller's *personal* Databricks identity, which each factory
240
+ stores separately. When that identity is not linked the factory asks for
241
+ the connect flow to be completed, offering its own connect page as a
242
+ URL-mode elicitation.
243
+
244
+ The router never relays that. It reports the refusal instead, naming the
245
+ page, which beats hardcoding a frontend route that will move.
246
+
247
+ The result is marked as an error because it is one: the connect never
248
+ happened and the tool never ran, and saying otherwise invites a caller to
249
+ read a refusal as a result. The code prefix matches the one
250
+ `Router.call_tool` puts on raised errors; an upstream *result* carrying
251
+ `is_error` never passes through that path, so without this the line would
252
+ not say which environment it came from.
253
+
254
+ One content block, not one per line, matching `_error_result` in `server`
255
+ and every other refusal the router produces. A client that renders only
256
+ the first text block would otherwise show the factory's message and drop
257
+ the connect line, which is the experience this exists to end.
258
+
259
+ With no usable URL the fallback names the environment's own host instead.
260
+ That address is the router's, built from the profile rather than taken
261
+ from the factory, so it gives the caller somewhere to start without
262
+ loosening what the origin check refuses to print.
263
+ """
264
+ connect = (
265
+ f"[{self.code}] Connect at {url}, then retry."
266
+ if url is not None
267
+ else (f"[{self.code}] This factory needs input the router cannot relay. Start at {self.profile['host']}.")
268
+ )
269
+ return types.CallToolResult(
270
+ content=[types.TextContent(text="\n".join([*lines, connect]))],
271
+ is_error=True,
272
+ )
@@ -0,0 +1,370 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Any
5
+
6
+ import pytest
7
+
8
+ pytest.importorskip("mcp")
9
+
10
+ import anyio # noqa: E402
11
+ import httpx2 # noqa: E402
12
+ import mcp_types as types # noqa: E402
13
+
14
+ from if_cli.cache import store_tokens # noqa: E402
15
+ from if_cli.config import Profile # noqa: E402
16
+ from if_cli.router.upstream import ConnectUrlRecorder, ProfileBearerAuth, Upstream # noqa: E402
17
+ from tests.servers import Response, ThreadedServer # noqa: E402
18
+
19
+
20
+ def _profile(host: str) -> Profile:
21
+ return {
22
+ "name": "a",
23
+ "host": host,
24
+ "audience": f"{host}/api",
25
+ "callback_port": 0,
26
+ "client_id": None,
27
+ "organization": None,
28
+ }
29
+
30
+
31
+ def test_auth_refuses_to_send_the_bearer_to_a_foreign_origin():
32
+ profile = _profile("https://factory.example")
33
+
34
+ async def run() -> httpx2.Response:
35
+ async def handler(_request: httpx2.Request) -> httpx2.Response:
36
+ return httpx2.Response(200)
37
+
38
+ async with httpx2.AsyncClient(
39
+ auth=ProfileBearerAuth(profile), transport=httpx2.MockTransport(handler)
40
+ ) as client:
41
+ return await client.get("https://other.example/mcp")
42
+
43
+ with pytest.raises(RuntimeError, match="refusing to send"):
44
+ anyio.run(run)
45
+
46
+
47
+ def test_auth_remints_the_token_once_on_a_401_and_retries():
48
+ """The 401-retry path, tested directly against the Auth class with a MockTransport.
49
+
50
+ A full end-to-end 401-then-refresh round trip through a real upstream MCP
51
+ session is not exercised here: `if-cli mcp` opens a fresh session per call
52
+ (see `Upstream._run`), and reliably forcing that session's *second* request
53
+ to see a 401 without also breaking its handshake requests is far more
54
+ fragile than isolating the retry behaviour in the auth flow itself.
55
+ """
56
+ idp = ThreadedServer(
57
+ lambda _request: Response(body={"access_token": "refreshed-token", "expires_in": 3600})
58
+ ).start()
59
+ try:
60
+ profile = _profile("https://factory.example")
61
+ store_tokens(
62
+ profile["host"],
63
+ {"access_token": "stale-token", "refresh_token": "refresh-tok", "expires_in": 3600},
64
+ f"{idp.origin}/token",
65
+ "test-client",
66
+ replace=True,
67
+ )
68
+ seen_auth: list[str | None] = []
69
+
70
+ async def run() -> httpx2.Response:
71
+ def handler(request: httpx2.Request) -> httpx2.Response:
72
+ seen_auth.append(request.headers.get("authorization"))
73
+ return httpx2.Response(401) if len(seen_auth) == 1 else httpx2.Response(200)
74
+
75
+ async with httpx2.AsyncClient(
76
+ auth=ProfileBearerAuth(profile), transport=httpx2.MockTransport(handler)
77
+ ) as client:
78
+ return await client.get(f"{profile['host']}/mcp")
79
+
80
+ response = anyio.run(run)
81
+ assert response.status_code == 200
82
+ assert seen_auth == ["Bearer stale-token", "Bearer refreshed-token"]
83
+ finally:
84
+ idp.close()
85
+
86
+
87
+ def _url_request(url: str, message: str = "Connect your Databricks account.") -> types.ElicitRequest:
88
+ return types.ElicitRequest(params=types.ElicitRequestURLParams(message=message, url=url))
89
+
90
+
91
+ def _input_required(*requests: types.ElicitRequest) -> types.InputRequiredResult:
92
+ return types.InputRequiredResult(
93
+ input_requests={f"r{index}": request for index, request in enumerate(requests)},
94
+ request_state="opaque",
95
+ )
96
+
97
+
98
+ def _texts(result: types.CallToolResult) -> list[str]:
99
+ """The refusal's lines, from the single text block it is required to be.
100
+
101
+ One block, so a client that renders only the first still sees the connect
102
+ line. `_error_result` in `server` is the same shape.
103
+ """
104
+ assert len(result.content) == 1
105
+ block = result.content[0]
106
+ assert isinstance(block, types.TextContent)
107
+ return block.text.split("\n")
108
+
109
+
110
+ class _FakeSession:
111
+ """Stands in for `client.session`, recording how `call_tool` was invoked."""
112
+
113
+ def __init__(self, result):
114
+ self._result = result
115
+ self.allow_input_required: bool | None = None
116
+
117
+ async def call_tool(self, _name, _arguments, *, allow_input_required=False):
118
+ self.allow_input_required = allow_input_required
119
+ return self._result
120
+
121
+
122
+ class _FakeClient:
123
+ def __init__(self, result):
124
+ self.session = _FakeSession(result)
125
+
126
+
127
+ def _upstream_returning(result, *, elicits: Any = None) -> tuple[Upstream, dict]:
128
+ """An `Upstream` whose `_run` drives the real closure against a fake client.
129
+
130
+ `elicits` stands in for a server on an older revision, which sends
131
+ `elicitation/create` as its own request before answering the call.
132
+ """
133
+ upstream = Upstream("prd", _profile("https://factory.example"), timeout=1)
134
+ seen: dict = {}
135
+
136
+ async def fake_run(action, *, elicitation_callback=None):
137
+ client = _FakeClient(result)
138
+ seen["elicitation_callback"] = elicitation_callback
139
+ if elicits is not None and elicitation_callback is not None:
140
+ seen["elicit_result"] = await elicitation_callback(None, elicits)
141
+ value = await action(client)
142
+ seen["allow_input_required"] = client.session.allow_input_required
143
+ return value
144
+
145
+ upstream._run = fake_run # ty: ignore[invalid-assignment]
146
+ return upstream, seen
147
+
148
+
149
+ def test_call_tool_names_the_connect_page_when_the_factory_asks_for_input():
150
+ """The refusal this exists for arrives as an `InputRequiredResult`, not a result.
151
+
152
+ Observed against foundryaz-dev on 14 September 2026: `run_sql_query` with no
153
+ linked Databricks identity returns `input_required` carrying a URL-mode
154
+ elicitation for the factory's connect page, and repeats it on every retry
155
+ that is still unconnected.
156
+ """
157
+ upstream, _ = _upstream_returning(_input_required(_url_request("https://factory.example/my-profile/developer")))
158
+
159
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
160
+
161
+ assert result.is_error
162
+ assert _texts(result) == [
163
+ "Connect your Databricks account.",
164
+ "[prd] Connect at https://factory.example/my-profile/developer, then retry.",
165
+ ]
166
+
167
+
168
+ def test_call_tool_takes_the_input_required_result_in_hand():
169
+ """Without this the SDK answers the elicitation and retries until it gives up."""
170
+ upstream, seen = _upstream_returning(_input_required(_url_request("https://factory.example/x")))
171
+
172
+ anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
173
+
174
+ assert seen["allow_input_required"] is True
175
+ assert isinstance(seen["elicitation_callback"], ConnectUrlRecorder)
176
+
177
+
178
+ def test_call_tool_passes_an_ordinary_result_straight_through():
179
+ success = types.CallToolResult(content=[types.TextContent(text="1 row")], is_error=False)
180
+ upstream, _ = _upstream_returning(success)
181
+
182
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
183
+
184
+ assert result is success
185
+
186
+
187
+ def test_call_tool_leaves_an_unrelated_failure_alone():
188
+ failure = types.CallToolResult(content=[types.TextContent(text="query_not_read_only")], is_error=True)
189
+ upstream, _ = _upstream_returning(failure)
190
+
191
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
192
+
193
+ assert _texts(result) == ["query_not_read_only"]
194
+
195
+
196
+ @pytest.mark.parametrize(
197
+ "url",
198
+ [
199
+ "https://phish.example/my-profile/developer",
200
+ "javascript:alert(1)",
201
+ # `urlparse` accepts these and only raises when `url_origin` reads `.port`.
202
+ "https://factory.example:99999/my-profile/developer",
203
+ "https://factory.example:abc/my-profile/developer",
204
+ ],
205
+ )
206
+ def test_call_tool_drops_a_request_whose_url_is_not_this_environments(url: str):
207
+ """A URL the router would print under a trusted environment code, so it is
208
+ checked the same way `ProfileBearerAuth` checks where a token may go."""
209
+ upstream, _ = _upstream_returning(_input_required(_url_request(url)))
210
+
211
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
212
+
213
+ assert result.is_error
214
+ assert _texts(result) == [
215
+ "[prd] This factory needs input the router cannot relay. Start at https://factory.example."
216
+ ]
217
+
218
+
219
+ def test_call_tool_drops_the_message_of_a_request_it_rejected():
220
+ """The message is printed a line above the environment code, and can name a
221
+ host in prose where the URL check would never reach it."""
222
+ upstream, _ = _upstream_returning(
223
+ _input_required(
224
+ _url_request("https://phish.example/first", message="Connect at https://phish.example/first."),
225
+ _url_request("https://factory.example/my-profile/developer"),
226
+ )
227
+ )
228
+
229
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
230
+
231
+ assert _texts(result) == [
232
+ "Connect your Databricks account.",
233
+ "[prd] Connect at https://factory.example/my-profile/developer, then retry.",
234
+ ]
235
+
236
+
237
+ def test_call_tool_takes_the_first_same_origin_url():
238
+ """Only the first request's URL is used, but every accepted request keeps its
239
+ message, so the second one's text still appears above the connect line."""
240
+ upstream, _ = _upstream_returning(
241
+ _input_required(
242
+ _url_request("https://factory.example/my-profile/developer"),
243
+ _url_request("https://factory.example/somewhere-else", message="Or here."),
244
+ )
245
+ )
246
+
247
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
248
+
249
+ assert _texts(result) == [
250
+ "Connect your Databricks account.",
251
+ "Or here.",
252
+ "[prd] Connect at https://factory.example/my-profile/developer, then retry.",
253
+ ]
254
+
255
+
256
+ def test_call_tool_ignores_a_form_mode_request():
257
+ """Answering a form means relaying it downstream, which the router does not do."""
258
+ form = types.ElicitRequest(
259
+ params=types.ElicitRequestFormParams(
260
+ message="Pick one.", requested_schema=types.ElicitRequestedSchema(properties={})
261
+ )
262
+ )
263
+ upstream, _ = _upstream_returning(_input_required(form))
264
+
265
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
266
+
267
+ assert result.is_error
268
+ assert _texts(result) == [
269
+ "Pick one.",
270
+ "[prd] This factory needs input the router cannot relay. Start at https://factory.example.",
271
+ ]
272
+
273
+
274
+ def test_declining_is_the_answer_to_any_elicitation_that_does_reach_the_callback():
275
+ recorder = ConnectUrlRecorder("https://factory.example")
276
+ params = types.ElicitRequestURLParams(message="Connect.", url="https://factory.example/my-profile/developer")
277
+
278
+ result = anyio.run(lambda: recorder(None, params))
279
+
280
+ assert result.action == "decline"
281
+ assert recorder.url == "https://factory.example/my-profile/developer"
282
+
283
+
284
+ def test_call_tool_names_the_page_a_down_level_server_elicited_in_band():
285
+ """Before 2026-07-28 the connect page arrives as its own `elicitation/create`,
286
+ and the call then answers with an ordinary refusal that names no page."""
287
+ refusal = types.CallToolResult(
288
+ content=[types.TextContent(text="Connect your Databricks account to run SQL queries.")], is_error=False
289
+ )
290
+ upstream, seen = _upstream_returning(
291
+ refusal,
292
+ elicits=types.ElicitRequestURLParams(message="Connect.", url="https://factory.example/my-profile/developer"),
293
+ )
294
+
295
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
296
+
297
+ assert seen["elicit_result"].action == "decline"
298
+ assert result.is_error
299
+ assert _texts(result) == [
300
+ "Connect your Databricks account to run SQL queries.",
301
+ "[prd] Connect at https://factory.example/my-profile/developer, then retry.",
302
+ ]
303
+
304
+
305
+ def test_a_down_level_url_from_a_foreign_origin_is_still_a_refusal():
306
+ """What makes it a refusal is the decline, not the host the URL named. The
307
+ call did not run either way, so the origin check decides what the caller is
308
+ pointed at and nothing else."""
309
+ success = types.CallToolResult(content=[types.TextContent(text="1 row")], is_error=False)
310
+ upstream, _ = _upstream_returning(
311
+ success,
312
+ elicits=types.ElicitRequestURLParams(message="Connect.", url="https://phish.example/connect"),
313
+ )
314
+
315
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
316
+
317
+ assert result.is_error
318
+ assert _texts(result) == [
319
+ "1 row",
320
+ "[prd] This factory needs input the router cannot relay. Start at https://factory.example.",
321
+ ]
322
+
323
+
324
+ def test_a_down_level_form_elicitation_leaves_the_result_alone():
325
+ """Only a URL-mode request means the connect flow. A form is a different
326
+ conversation, and the router declining it does not make the result a refusal."""
327
+ success = types.CallToolResult(content=[types.TextContent(text="1 row")], is_error=False)
328
+ upstream, _ = _upstream_returning(
329
+ success,
330
+ elicits=types.ElicitRequestFormParams(
331
+ message="Pick one.", requested_schema=types.ElicitRequestedSchema(properties={})
332
+ ),
333
+ )
334
+
335
+ result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
336
+
337
+ assert result is success
338
+
339
+
340
+ def test_a_dropped_connect_url_is_reported_on_stderr(capsys):
341
+ """Silently dropping it reads the same as an elicitation the router will not
342
+ answer, and the two are very different events."""
343
+ upstream, _ = _upstream_returning(_input_required(_url_request("https://phish.example/connect")))
344
+
345
+ anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
346
+
347
+ stderr = capsys.readouterr().err
348
+ assert "[if-cli mcp] WARN dropped a connect URL that is not on https://factory.example" in stderr
349
+ assert "https://phish.example/connect" in stderr
350
+
351
+
352
+ def test_the_sdk_still_offers_the_surface_call_tool_reaches_for():
353
+ """`_FakeSession` makes `.session.call_tool(allow_input_required=...)` exist by
354
+ construction, so only this notices an SDK release that moves it. The extra
355
+ floor is `mcp>=2.1,<3`, and any 2.x minor could.
356
+ """
357
+ from mcp.client import Client
358
+ from mcp.client.session import ClientSession
359
+
360
+ assert "session" in dir(Client)
361
+ assert "allow_input_required" in inspect.signature(ClientSession.call_tool).parameters
362
+
363
+
364
+ def test_the_sdk_client_still_cannot_drive_the_loop_itself():
365
+ """The reason `call_tool` reaches past `Client` to the session at all. If a
366
+ release adds the parameter here, prefer it and drop the `.session` call.
367
+ """
368
+ from mcp.client import Client
369
+
370
+ assert "allow_input_required" not in inspect.signature(Client.call_tool).parameters
@@ -1,107 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import sys
4
- from typing import Any
5
-
6
- import anyio
7
- import httpx2
8
- import mcp_types as types
9
- from mcp.client import Client
10
- from mcp.client.streamable_http import streamable_http_client
11
-
12
- from if_cli.config import Profile
13
- from if_cli.http import parse_url, url_origin
14
- from if_cli.oauth import get_valid_token
15
-
16
- if sys.version_info >= (3, 11):
17
- _BaseExceptionGroup = BaseExceptionGroup # noqa: F821 - builtin from 3.11 on, guarded above
18
- else: # pragma: no cover - exercised only on Python < 3.11
19
- # anyio pulls in this backport for exactly that case, so it is always
20
- # importable here when the builtin is not.
21
- from exceptiongroup import BaseExceptionGroup as _BaseExceptionGroup # ty: ignore[unresolved-import]
22
-
23
-
24
- def _leaf_exception(error: BaseException) -> BaseException:
25
- """Unwrap nested single-member `BaseExceptionGroup`s down to their real cause.
26
-
27
- anyio/httpx2 run request handling in task groups, so a failure inside
28
- `ProfileBearerAuth` (e.g. the `CliError` login hint from `get_valid_token`)
29
- reaches the caller wrapped in one or more `BaseExceptionGroup`s, and a plain
30
- `str(error)` on those prints "unhandled errors in a TaskGroup" instead of the
31
- real message. A group with several leaves has no single real cause, so those
32
- are joined instead of picked from.
33
- """
34
- if isinstance(error, _BaseExceptionGroup):
35
- leaves = [_leaf_exception(sub) for sub in error.exceptions]
36
- if len(leaves) == 1:
37
- return leaves[0]
38
- return RuntimeError("; ".join(str(leaf) for leaf in leaves))
39
- return error
40
-
41
-
42
- class ProfileBearerAuth(httpx2.Auth):
43
- """Injects a fresh `if-cli` bearer token per request; re-mints once on a 401.
44
-
45
- Never sends a profile's token to any origin but its own factory host —
46
- the rule from AGENTS.md, made mechanical.
47
- """
48
-
49
- def __init__(self, profile: Profile) -> None:
50
- self._profile = profile
51
-
52
- async def async_auth_flow(self, request: httpx2.Request): # noqa: ANN201 - httpx2.Auth's own generator protocol
53
- origin = url_origin(parse_url(str(request.url)))
54
- if origin != self._profile["host"]:
55
- raise RuntimeError(
56
- f"refusing to send the '{self._profile['name']}' profile's bearer token to {origin}; "
57
- f"its factory origin is {self._profile['host']}"
58
- )
59
- token = await anyio.to_thread.run_sync(get_valid_token, self._profile)
60
- request.headers["Authorization"] = f"Bearer {token}"
61
- response = yield request
62
- if response.status_code == 401:
63
- token = await anyio.to_thread.run_sync(lambda: get_valid_token(self._profile, force_refresh=True))
64
- request.headers["Authorization"] = f"Bearer {token}"
65
- yield request
66
-
67
-
68
- class Upstream:
69
- """One factory environment's `/mcp` endpoint, reached with its own `if-cli` profile.
70
-
71
- The factory's streamable-HTTP transport is stateless, so every call opens
72
- its own client session (connect, initialize, call, close) instead of
73
- holding a long-lived connection open for the process lifetime. That keeps
74
- this class small and avoids babysitting three persistent connections.
75
- """
76
-
77
- def __init__(self, code: str, profile: Profile, *, timeout: float) -> None:
78
- self.code = code
79
- self.profile = profile
80
- self.timeout = timeout
81
-
82
- async def _run(self, action):
83
- url = f"{self.profile['host']}/mcp"
84
- try:
85
- async with httpx2.AsyncClient(auth=ProfileBearerAuth(self.profile), timeout=self.timeout) as http_client:
86
- async with Client(streamable_http_client(url, http_client=http_client)) as client:
87
- return await action(client)
88
- except _BaseExceptionGroup as error:
89
- raise _leaf_exception(error) from error
90
-
91
- async def list_tools(self) -> list[dict[str, Any]]:
92
- async def collect(client: Client) -> list[dict[str, Any]]:
93
- collected: list[dict[str, Any]] = []
94
- cursor: str | None = None
95
- while True:
96
- result = await client.list_tools(cursor=cursor)
97
- collected.extend(
98
- tool.model_dump(by_alias=True, mode="json", exclude_unset=True) for tool in result.tools
99
- )
100
- if not result.next_cursor:
101
- return collected
102
- cursor = result.next_cursor
103
-
104
- return await self._run(collect)
105
-
106
- async def call_tool(self, name: str, arguments: dict[str, Any]) -> types.CallToolResult:
107
- return await self._run(lambda client: client.call_tool(name, arguments))
@@ -1,80 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import pytest
4
-
5
- pytest.importorskip("mcp")
6
-
7
- import anyio # noqa: E402
8
- import httpx2 # noqa: E402
9
-
10
- from if_cli.cache import store_tokens # noqa: E402
11
- from if_cli.config import Profile # noqa: E402
12
- from if_cli.router.upstream import ProfileBearerAuth # noqa: E402
13
- from tests.servers import Response, ThreadedServer # noqa: E402
14
-
15
-
16
- def _profile(host: str) -> Profile:
17
- return {
18
- "name": "a",
19
- "host": host,
20
- "audience": f"{host}/api",
21
- "callback_port": 0,
22
- "client_id": None,
23
- "organization": None,
24
- }
25
-
26
-
27
- def test_auth_refuses_to_send_the_bearer_to_a_foreign_origin():
28
- profile = _profile("https://factory.example")
29
-
30
- async def run() -> httpx2.Response:
31
- async def handler(_request: httpx2.Request) -> httpx2.Response:
32
- return httpx2.Response(200)
33
-
34
- async with httpx2.AsyncClient(
35
- auth=ProfileBearerAuth(profile), transport=httpx2.MockTransport(handler)
36
- ) as client:
37
- return await client.get("https://other.example/mcp")
38
-
39
- with pytest.raises(RuntimeError, match="refusing to send"):
40
- anyio.run(run)
41
-
42
-
43
- def test_auth_remints_the_token_once_on_a_401_and_retries():
44
- """The 401-retry path, tested directly against the Auth class with a MockTransport.
45
-
46
- A full end-to-end 401-then-refresh round trip through a real upstream MCP
47
- session is not exercised here: `if-cli mcp` opens a fresh session per call
48
- (see `Upstream._run`), and reliably forcing that session's *second* request
49
- to see a 401 without also breaking its handshake requests is far more
50
- fragile than isolating the retry behaviour in the auth flow itself.
51
- """
52
- idp = ThreadedServer(
53
- lambda _request: Response(body={"access_token": "refreshed-token", "expires_in": 3600})
54
- ).start()
55
- try:
56
- profile = _profile("https://factory.example")
57
- store_tokens(
58
- profile["host"],
59
- {"access_token": "stale-token", "refresh_token": "refresh-tok", "expires_in": 3600},
60
- f"{idp.origin}/token",
61
- "test-client",
62
- replace=True,
63
- )
64
- seen_auth: list[str | None] = []
65
-
66
- async def run() -> httpx2.Response:
67
- def handler(request: httpx2.Request) -> httpx2.Response:
68
- seen_auth.append(request.headers.get("authorization"))
69
- return httpx2.Response(401) if len(seen_auth) == 1 else httpx2.Response(200)
70
-
71
- async with httpx2.AsyncClient(
72
- auth=ProfileBearerAuth(profile), transport=httpx2.MockTransport(handler)
73
- ) as client:
74
- return await client.get(f"{profile['host']}/mcp")
75
-
76
- response = anyio.run(run)
77
- assert response.status_code == 200
78
- assert seen_auth == ["Bearer stale-token", "Bearer refreshed-token"]
79
- finally:
80
- idp.close()