insightfactory-cli 1.0.3.dev20__py3-none-any.whl → 1.0.3.dev21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
if_cli/commands/mcp.py CHANGED
@@ -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 [])
if_cli/router/log.py ADDED
@@ -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")
if_cli/router/server.py CHANGED
@@ -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
 
if_cli/router/upstream.py CHANGED
@@ -12,6 +12,7 @@ from mcp.client.streamable_http import streamable_http_client
12
12
  from if_cli.config import Profile
13
13
  from if_cli.http import parse_url, url_origin
14
14
  from if_cli.oauth import get_valid_token
15
+ from if_cli.router.log import log
15
16
 
16
17
  if sys.version_info >= (3, 11):
17
18
  _BaseExceptionGroup = BaseExceptionGroup # noqa: F821 - builtin from 3.11 on, guarded above
@@ -65,6 +66,107 @@ class ProfileBearerAuth(httpx2.Auth):
65
66
  yield request
66
67
 
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
+
68
170
  class Upstream:
69
171
  """One factory environment's `/mcp` endpoint, reached with its own `if-cli` profile.
70
172
 
@@ -79,11 +181,14 @@ class Upstream:
79
181
  self.profile = profile
80
182
  self.timeout = timeout
81
183
 
82
- async def _run(self, action):
184
+ async def _run(self, action, *, elicitation_callback=None):
83
185
  url = f"{self.profile['host']}/mcp"
84
186
  try:
85
187
  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:
188
+ async with Client(
189
+ streamable_http_client(url, http_client=http_client),
190
+ elicitation_callback=elicitation_callback,
191
+ ) as client:
87
192
  return await action(client)
88
193
  except _BaseExceptionGroup as error:
89
194
  raise _leaf_exception(error) from error
@@ -104,4 +209,64 @@ class Upstream:
104
209
  return await self._run(collect)
105
210
 
106
211
  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))
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
+ )
@@ -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
@@ -17,17 +17,18 @@ if_cli/commands/api.py,sha256=XeVhhD39Oho4xNbMJIGh1I6vi53on5EGGsAtsb_5TAg,9016
17
17
  if_cli/commands/config.py,sha256=1KAxjme897NIbXTGBw1rNJlXnFvhbEUf5MAli8A93ns,1724
18
18
  if_cli/commands/login.py,sha256=vxsYcRZWsaNEzekyN1zy4ROJO-OCGUpjaVUBhtbK1LY,3255
19
19
  if_cli/commands/logout.py,sha256=TW0P4_tXcTtiHHR73xVPHyhlbOWD1jBnkZsMImCiCBw,668
20
- if_cli/commands/mcp.py,sha256=nXc6qT-psryLDm8Yjmu_2IFDOEnNIAjCkCT5UTDZP4M,6553
20
+ if_cli/commands/mcp.py,sha256=uU70j13n1WwkiyQigUTHPIIpc1B2PZO2O4wLIFDB87k,6586
21
21
  if_cli/commands/profiles.py,sha256=tgZP5sMqzbFHXlfvglL2plsGdU0M3joo3wvQKX45J1U,3950
22
22
  if_cli/commands/set_token.py,sha256=BVyv_QmByvllr04pE2sz8JFEscHbIgOC-7ZPg61PRoc,1886
23
23
  if_cli/commands/token.py,sha256=CyiXkyH7vxgH91-n-XJzeHnLI6mZJwO9Js5AHvfHaeM,1120
24
24
  if_cli/router/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
25
  if_cli/router/catalog.py,sha256=Er2ZbWTwDUVU-qBGCvyA0bIlFk3UYw_6sVUlYwBhMpI,4992
26
+ if_cli/router/log.py,sha256=YLWeHUb05HUnS3XHubCwy7oLZAUEGAiHcZysz_YMAAo,412
26
27
  if_cli/router/policy.py,sha256=CclbjuOA5mwwoQiNDa17iQeN9eXRbfy11bS9JFd8gL4,2840
27
- if_cli/router/server.py,sha256=kTQwvUVyco8QjOfjPHZF8LYUmHi8R5DdIk-Wxr5yHVA,12176
28
- if_cli/router/upstream.py,sha256=qFWSQ0lDTXM_8Q7vq8X5sWP2cMWXx5Hn-WwiLL7r3UM,4631
29
- insightfactory_cli-1.0.3.dev20.dist-info/METADATA,sha256=o78rj9GHshhGzIAZfPSRhyuGh-RMdlvOsk-PKaqCpkk,17509
30
- insightfactory_cli-1.0.3.dev20.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
31
- insightfactory_cli-1.0.3.dev20.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
32
- insightfactory_cli-1.0.3.dev20.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
33
- insightfactory_cli-1.0.3.dev20.dist-info/RECORD,,
28
+ if_cli/router/server.py,sha256=RkS6Ddann-vAU0ZQAe2QaEB9dLBvEC33V9O-G4WSIy8,12116
29
+ if_cli/router/upstream.py,sha256=dmwS7qSLnHiY7dEYpVENkfYIHs3DxkhhMB1SV66lE2Y,13104
30
+ insightfactory_cli-1.0.3.dev21.dist-info/METADATA,sha256=ieTLsUPspC42Fn07s2uGN7Ra5r01bYzCMr6Xne2yE5M,17904
31
+ insightfactory_cli-1.0.3.dev21.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
32
+ insightfactory_cli-1.0.3.dev21.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
33
+ insightfactory_cli-1.0.3.dev21.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
34
+ insightfactory_cli-1.0.3.dev21.dist-info/RECORD,,