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.
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/PKG-INFO +7 -1
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/README.md +6 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/pyproject.toml +1 -1
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/mcp.py +2 -1
- insightfactory_cli-1.0.3.dev21/src/if_cli/router/log.py +13 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/server.py +1 -5
- insightfactory_cli-1.0.3.dev21/src/if_cli/router/upstream.py +272 -0
- insightfactory_cli-1.0.3.dev21/tests/test_router_upstream.py +370 -0
- insightfactory_cli-1.0.3.dev20/src/if_cli/router/upstream.py +0 -107
- insightfactory_cli-1.0.3.dev20/tests/test_router_upstream.py +0 -80
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.github/workflows/ci.yml +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.github/workflows/claude.yml +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.github/workflows/release.yml +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.gitignore +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.python-version +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/AGENTS.md +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/CLAUDE.md +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/LICENSE +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/__init__.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/__main__.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/assets/__init__.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/cache.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/callback_page.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/cli.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/colour.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/__init__.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/api.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/config.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/login.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/logout.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/profiles.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/set_token.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/token.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/config.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/constants.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/http.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/main.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/oauth.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/__init__.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/catalog.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/policy.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/runtime.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/__init__.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/cache_writer.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/conftest.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/helpers.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/servers.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_api.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_api_command.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_cache.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_cli.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_config.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_config_command.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_login.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_mcp_command.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_oauth.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_oauth_flow.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_oauth_force_refresh.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_profiles.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_programmatic_api.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_catalog.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_policy.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_server.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_runtime.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_set_token.py +0 -0
- {insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_token.py +0 -0
- {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.
|
|
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
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/mcp.py
RENAMED
|
@@ -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.
|
|
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")
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/server.py
RENAMED
|
@@ -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()
|
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.github/workflows/claude.yml
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/assets/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/callback_page.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/__init__.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/api.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/config.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/login.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/logout.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/profiles.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/set_token.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/commands/token.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/__init__.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/catalog.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/src/if_cli/router/policy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_config_command.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_oauth_force_refresh.py
RENAMED
|
File without changes
|
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_programmatic_api.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_catalog.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_policy.py
RENAMED
|
File without changes
|
{insightfactory_cli-1.0.3.dev20 → insightfactory_cli-1.0.3.dev21}/tests/test_router_server.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|