insightfactory-cli 1.1.2.dev29__tar.gz → 1.1.2.dev31__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 (75) hide show
  1. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/.gitignore +1 -0
  2. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/CHANGELOG.md +13 -0
  3. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/PKG-INFO +9 -1
  4. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/README.md +8 -0
  5. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/pyproject.toml +1 -1
  6. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/mcp.py +4 -1
  7. insightfactory_cli-1.1.2.dev31/src/if_cli/router/log.py +20 -0
  8. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/router/server.py +24 -1
  9. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/router/upstream.py +44 -1
  10. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_mcp_command.py +6 -6
  11. insightfactory_cli-1.1.2.dev31/tests/test_router_log.py +23 -0
  12. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_router_upstream.py +36 -5
  13. insightfactory_cli-1.1.2.dev29/src/if_cli/router/log.py +0 -13
  14. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/.github/workflows/ci.yml +0 -0
  15. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/.github/workflows/claude.yml +0 -0
  16. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/.github/workflows/release.yml +0 -0
  17. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/.python-version +0 -0
  18. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/AGENTS.md +0 -0
  19. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/CLAUDE.md +0 -0
  20. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/LICENSE +0 -0
  21. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/__init__.py +0 -0
  22. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/__main__.py +0 -0
  23. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/assets/__init__.py +0 -0
  24. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
  25. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/cache.py +0 -0
  26. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/callback_page.py +0 -0
  27. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/cli.py +0 -0
  28. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/colour.py +0 -0
  29. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/__init__.py +0 -0
  30. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/api.py +0 -0
  31. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/config.py +0 -0
  32. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/login.py +0 -0
  33. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/logout.py +0 -0
  34. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/profiles.py +0 -0
  35. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/set_token.py +0 -0
  36. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/commands/token.py +0 -0
  37. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/config.py +0 -0
  38. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/constants.py +0 -0
  39. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/http.py +0 -0
  40. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/main.py +0 -0
  41. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/oauth.py +0 -0
  42. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/router/__init__.py +0 -0
  43. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/router/catalog.py +0 -0
  44. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/router/content.py +0 -0
  45. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/router/policy.py +0 -0
  46. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/src/if_cli/runtime.py +0 -0
  47. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/__init__.py +0 -0
  48. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/cache_writer.py +0 -0
  49. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/conftest.py +0 -0
  50. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/helpers.py +0 -0
  51. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/servers.py +0 -0
  52. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_api.py +0 -0
  53. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_api_command.py +0 -0
  54. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_cache.py +0 -0
  55. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_cli.py +0 -0
  56. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_config.py +0 -0
  57. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_config_command.py +0 -0
  58. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_login.py +0 -0
  59. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_oauth.py +0 -0
  60. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_oauth_flow.py +0 -0
  61. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_oauth_force_refresh.py +0 -0
  62. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_profiles.py +0 -0
  63. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_programmatic_api.py +0 -0
  64. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_release_tools.py +0 -0
  65. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_router_catalog.py +0 -0
  66. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_router_content.py +0 -0
  67. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_router_policy.py +0 -0
  68. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_router_resources.py +0 -0
  69. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_router_server.py +0 -0
  70. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_runtime.py +0 -0
  71. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_set_token.py +0 -0
  72. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tests/test_token.py +0 -0
  73. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tools/check_release.py +0 -0
  74. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/tools/release_notes.py +0 -0
  75. {insightfactory_cli-1.1.2.dev29 → insightfactory_cli-1.1.2.dev31}/uv.lock +0 -0
@@ -9,3 +9,4 @@ __pycache__/
9
9
  dist/
10
10
  build/
11
11
  .DS_Store
12
+ .tmp/
@@ -31,6 +31,12 @@ last one has been released, so a cycle's PRs share a section.
31
31
 
32
32
  ### Changed
33
33
 
34
+ - The router's stderr lines now carry the elapsed time since startup, and it logs when
35
+ the catalogue build starts and when it answers its first `initialize`, so an operator
36
+ can tell a slow process apart from a slow factory and a client that called before the
37
+ handshake finished. `if-cli mcp` also now logs a `starting on Python <version>` line
38
+ before doing anything else, so a process that dies during argument parsing, config
39
+ load, or profile lookup still leaves one line on stderr instead of none.
34
40
  - `release-guard` asks every PR into `develop` for a `CHANGELOG.md` entry, and asks
35
41
  for a version bump only when `develop` is still on the version `main` is on. One
36
42
  bump opens a release cycle and the PRs after it add to the section it opened,
@@ -39,6 +45,13 @@ last one has been released, so a cycle's PRs share a section.
39
45
  has to land above the released version, not merely above the base, and a PR into an
40
46
  open cycle has to add a line rather than reword one.
41
47
 
48
+ ### Fixed
49
+
50
+ - The router no longer turns a `confirmed:false` preview from a mutating tool into an
51
+ error pointing at a web UI. That path only belongs to the Databricks connect flow;
52
+ a form-mode approval gate now comes back as an ordinary result, so the caller
53
+ confirms by calling again with `confirmed:true` as intended.
54
+
42
55
  ## 1.1.1
43
56
 
44
57
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.1.2.dev29
3
+ Version: 1.1.2.dev31
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>
@@ -265,6 +265,14 @@ uv tool install 'insightfactory-cli[mcp]'
265
265
  uvx --from 'insightfactory-cli[mcp]' if-cli mcp foundry
266
266
  ```
267
267
 
268
+ The first `uvx` launch resolves and downloads the package from PyPI before it runs, which
269
+ added roughly four to six seconds on top of the usual startup in local testing, against
270
+ under two seconds once `uv`'s cache was warm. A client with a fixed handshake budget
271
+ (Claude Code allows 30 seconds) usually has room for that, but a slow network can close
272
+ the gap. Two ways to avoid paying it on every launch: `uv tool install 'insightfactory-cli[mcp]'`
273
+ puts a real script on `PATH` with no resolve step, or run the same `uvx --from ...` command
274
+ by hand once before pointing a client at it, which warms `uv`'s cache for the launches after.
275
+
268
276
  Add it to `.mcp.json`:
269
277
 
270
278
  ```json
@@ -241,6 +241,14 @@ uv tool install 'insightfactory-cli[mcp]'
241
241
  uvx --from 'insightfactory-cli[mcp]' if-cli mcp foundry
242
242
  ```
243
243
 
244
+ The first `uvx` launch resolves and downloads the package from PyPI before it runs, which
245
+ added roughly four to six seconds on top of the usual startup in local testing, against
246
+ under two seconds once `uv`'s cache was warm. A client with a fixed handshake budget
247
+ (Claude Code allows 30 seconds) usually has room for that, but a slow network can close
248
+ the gap. Two ways to avoid paying it on every launch: `uv tool install 'insightfactory-cli[mcp]'`
249
+ puts a real script on `PATH` with no resolve step, or run the same `uvx --from ...` command
250
+ by hand once before pointing a client at it, which warms `uv`'s cache for the launches after.
251
+
244
252
  Add it to `.mcp.json`:
245
253
 
246
254
  ```json
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.1.2.dev29"
3
+ version = "1.1.2.dev31"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib.util
4
+ import platform
4
5
  import sys
5
6
 
6
7
  from if_cli.cli import Options, parse_args
7
8
  from if_cli.config import config_file, get_profile, list_factories, load_config, parse_factory_section
8
9
  from if_cli.http import parse_timeout_seconds
10
+ from if_cli.router.log import log
9
11
  from if_cli.runtime import die
10
12
 
11
13
  MCP_OPTIONS: Options = {
@@ -75,6 +77,8 @@ def _stray_positional(rest: list[str]) -> str | None:
75
77
 
76
78
 
77
79
  def mcp_command(argv: list[str]) -> None:
80
+ log(f"starting on Python {platform.python_version()}")
81
+
78
82
  if "-h" in argv or "--help" in argv:
79
83
  sys.stdout.write(MCP_USAGE)
80
84
  return
@@ -146,7 +150,6 @@ def mcp_command(argv: list[str]) -> None:
146
150
  # import bug inside if_cli.router propagates instead of reading as a missing extra.
147
151
  if importlib.util.find_spec("mcp") is None:
148
152
  die(MISSING_MCP_EXTRA)
149
- from if_cli.router.log import log
150
153
  from if_cli.router.server import build_router, run_server
151
154
 
152
155
  timeout = parse_timeout_seconds(values["timeout"], "--timeout")
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import time
5
+
6
+ # Captured at import: this module loads before the router does anything else
7
+ # diagnosable, so it stands in for process start. Two `log()` lines can then
8
+ # be subtracted to time the phase between them.
9
+ _START = time.monotonic()
10
+
11
+
12
+ def log(message: str) -> None:
13
+ """Write one timestamped diagnostic line to stderr.
14
+
15
+ The router speaks MCP over stdout, so stderr is the only channel that can
16
+ carry anything to the operator. This lives in its own module because both
17
+ `server` and `upstream` write to it and `server` imports `upstream`.
18
+ """
19
+ elapsed_ms = round((time.monotonic() - _START) * 1000)
20
+ sys.stderr.write(f"[if-cli mcp] +{elapsed_ms}ms {message}\n")
@@ -9,7 +9,7 @@ from typing import Any, TypeVar
9
9
  import anyio
10
10
  import mcp_types as types
11
11
  from mcp.server import Server
12
- from mcp.server.context import ServerRequestContext
12
+ from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
13
13
  from mcp.server.stdio import stdio_server
14
14
  from mcp.shared.exceptions import MCPError
15
15
 
@@ -137,6 +137,7 @@ class Router:
137
137
 
138
138
  async def build_catalog(self) -> list[types.Tool]:
139
139
  """Build the merged catalogue. Safe to call again to pick up a new login."""
140
+ log(f"catalogue: building from {self.config.catalog_source}")
140
141
  catalogues = await self._catalogues()
141
142
  source = catalogues.get(self.config.catalog_source)
142
143
  if source is None:
@@ -536,6 +537,27 @@ def advertised_extensions(router: Router) -> dict[str, dict[str, Any]]:
536
537
  return {SKILLS_EXTENSION: {}} if router.config.skills else {}
537
538
 
538
539
 
540
+ def _log_first_initialize() -> ServerMiddleware[Any]:
541
+ """Log once the router has answered a client's `initialize`.
542
+
543
+ Read against `prime_catalog`'s startup log lines, this tells an operator
544
+ apart a slow process (nothing logged yet), a slow factory (catalogue
545
+ build still running past this point), and a client that called before
546
+ the handshake (a tool call logged before this line ever appears).
547
+ """
548
+ logged = False
549
+
550
+ async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
551
+ nonlocal logged
552
+ result = await call_next(ctx)
553
+ if not logged and ctx.method == "initialize":
554
+ logged = True
555
+ log("answered the first initialize")
556
+ return result
557
+
558
+ return middleware
559
+
560
+
539
561
  async def _serve(router: Router) -> None:
540
562
  server: Server[Any] = Server(
541
563
  SERVER_NAME,
@@ -553,6 +575,7 @@ async def _serve(router: Router) -> None:
553
575
  # the handshake rebuilds its capabilities from `server.extensions` once the
554
576
  # protocol version is known, and ignores the ones computed here.
555
577
  server.extensions = advertised_extensions(router)
578
+ server.middleware.append(_log_first_initialize())
556
579
  # Answer `initialize` immediately: the catalogue build runs as a background
557
580
  # task so one hung factory reads as a slow tools/list, not a failed
558
581
  # handshake (clients cap startup time).
@@ -138,6 +138,22 @@ class ConnectUrlRecorder:
138
138
  return types.ElicitResult(action="decline")
139
139
 
140
140
 
141
+ def _has_url_request(result: types.InputRequiredResult) -> bool:
142
+ """Whether any of the result's requests is the Databricks connect flow.
143
+
144
+ A request in any other mode (today, only `form`) gates a preview behind a
145
+ confirmation rather than asking for a trip to a browser, so its presence
146
+ alone never puts the result on the connect path. A URL-mode request does,
147
+ even one whose URL fails the origin check in `_connect_url` and so
148
+ contributes no link: the call still did not run, and the caller still
149
+ needs the existing refusal, not a result that reads as success.
150
+ """
151
+ return any(
152
+ isinstance(getattr(request, "params", None), types.ElicitRequestURLParams)
153
+ for request in (result.input_requests or {}).values()
154
+ )
155
+
156
+
141
157
  def _relayable_requests(result: types.InputRequiredResult, origin: str) -> tuple[list[str], str | None]:
142
158
  """The messages this router will repeat, and the first connect URL it will name.
143
159
 
@@ -279,7 +295,16 @@ class Upstream:
279
295
  result = await self._run(call, elicitation_callback=recorder)
280
296
  if isinstance(result, types.InputRequiredResult):
281
297
  lines, url = _relayable_requests(result, self.profile["host"])
282
- return self._connect_refusal(lines, url)
298
+ if _has_url_request(result):
299
+ return self._connect_refusal(lines, url)
300
+ # Every mutating tool gates a `confirmed:false` call behind a form-mode
301
+ # elicitation once the router advertises elicitation at all (see
302
+ # `ConnectUrlRecorder`). That gate is a preview, not a connect flow: the
303
+ # factory's own answer to a client with no elicitation capability is a
304
+ # plain `pending_confirmation` result, and the router owes the caller
305
+ # the closest thing it can relay to that, not an error pointing at a
306
+ # web UI the caller was never asked to visit.
307
+ return self._preview_result(lines)
283
308
  if recorder.declined_url_request:
284
309
  # The older-revision path. The server elicited in band and carried on
285
310
  # once the router declined, so the connect page is in the recorder
@@ -290,6 +315,24 @@ class Upstream:
290
315
  return self._connect_refusal(_result_texts(result), recorder.url)
291
316
  return result
292
317
 
318
+ def _preview_result(self, lines: list[str]) -> types.CallToolResult:
319
+ """A form-mode approval gate, relayed as the ordinary result it is.
320
+
321
+ This loses the structured `pending_confirmation` JSON a client with no
322
+ elicitation capability would see (the `approvalId`, `preview`,
323
+ `impactedResources`): the router has only the elicitation's message
324
+ text to work with, not the tool's own result body, which the factory
325
+ never sends once it has decided to gate the call. The message still
326
+ carries what a caller needs, and the caller still proceeds the same
327
+ way, by calling again with `confirmed:true`.
328
+
329
+ Not an error: the call is still pending, exactly as it would be for a
330
+ client that never advertised elicitation, and marking this `is_error`
331
+ would make a normal preview indistinguishable from the connect refusal
332
+ it is not.
333
+ """
334
+ return types.CallToolResult(content=[types.TextContent(text="\n".join(lines))], is_error=False)
335
+
293
336
  def _connect_refusal(self, lines: list[str], url: str | None) -> types.CallToolResult:
294
337
  """Turn the factory's request for input into a refusal naming the page to visit.
295
338
 
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import subprocess
6
7
  import sys
7
8
  import time
@@ -802,11 +803,10 @@ def test_mcp_command_logs_the_resolved_factory_mapping_at_startup(tmp_path: Path
802
803
 
803
804
  anyio.run(scenario)
804
805
  stderr_text = stderr_path.read_text(encoding="utf-8")
805
- expected = (
806
- f"[if-cli mcp] foundry: dev=profile-a {server_a.origin} (writable); "
807
- f"tst=profile-b {server_b.origin}; catalog=dev"
806
+ expected = re.escape(
807
+ f"foundry: dev=profile-a {server_a.origin} (writable); tst=profile-b {server_b.origin}; catalog=dev"
808
808
  )
809
- assert expected in stderr_text
809
+ assert re.search(rf"\[if-cli mcp\] \+\d+ms {expected}", stderr_text)
810
810
  finally:
811
811
  server_a.close()
812
812
  server_b.close()
@@ -827,8 +827,8 @@ def test_mcp_command_logs_environments_label_without_a_factory(tmp_path: Path):
827
827
 
828
828
  anyio.run(scenario)
829
829
  stderr_text = stderr_path.read_text(encoding="utf-8")
830
- expected = f"[if-cli mcp] environments: a=profile-a {server.origin} (writable); catalog=a"
831
- assert expected in stderr_text
830
+ expected = re.escape(f"environments: a=profile-a {server.origin} (writable); catalog=a")
831
+ assert re.search(rf"\[if-cli mcp\] \+\d+ms {expected}", stderr_text)
832
832
  finally:
833
833
  server.close()
834
834
 
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from if_cli.router import log as log_module
4
+
5
+
6
+ def test_log_prefixes_the_elapsed_time_since_process_start(monkeypatch, capsys):
7
+ monkeypatch.setattr(log_module, "_START", 10.0)
8
+ monkeypatch.setattr(log_module.time, "monotonic", lambda: 10.25)
9
+
10
+ log_module.log("ready over stdio")
11
+
12
+ assert capsys.readouterr().err == "[if-cli mcp] +250ms ready over stdio\n"
13
+
14
+
15
+ def test_log_keeps_the_grep_prefix_first(monkeypatch, capsys):
16
+ """`[if-cli mcp]` must stay the first thing on the line, timestamp or not:
17
+ it is what an operator (or a script) greps a stdio transcript for."""
18
+ monkeypatch.setattr(log_module, "_START", 0.0)
19
+ monkeypatch.setattr(log_module.time, "monotonic", lambda: 0.0)
20
+
21
+ log_module.log("WARN dropped a connect URL")
22
+
23
+ assert capsys.readouterr().err.startswith("[if-cli mcp] +0ms ")
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
+ import re
4
5
  from typing import Any
5
6
 
6
7
  import pytest
@@ -253,21 +254,49 @@ def test_call_tool_takes_the_first_same_origin_url():
253
254
  ]
254
255
 
255
256
 
256
- def test_call_tool_ignores_a_form_mode_request():
257
- """Answering a form means relaying it downstream, which the router does not do."""
257
+ def test_call_tool_relays_a_form_mode_request_as_a_preview_not_an_error():
258
+ """A `confirmed:false` mutating call gates behind a form-mode elicitation once
259
+ the router advertises elicitation at all; it is a preview, not a connect
260
+ flow, so it comes back as an ordinary result the caller can act on by
261
+ calling again with `confirmed:true`."""
258
262
  form = types.ElicitRequest(
259
263
  params=types.ElicitRequestFormParams(
260
- message="Pick one.", requested_schema=types.ElicitRequestedSchema(properties={})
264
+ message="Ready to add **1** task(s) to production line **NEIL_ROUTER_DEMO**. Ask the user to confirm.",
265
+ requested_schema=types.ElicitRequestedSchema(properties={}),
261
266
  )
262
267
  )
263
268
  upstream, _ = _upstream_returning(_input_required(form))
264
269
 
270
+ result = anyio.run(lambda: upstream.call_tool("add_tasks_to_production_line", {}))
271
+
272
+ assert not result.is_error
273
+ assert _texts(result) == [
274
+ "Ready to add **1** task(s) to production line **NEIL_ROUTER_DEMO**. Ask the user to confirm."
275
+ ]
276
+
277
+
278
+ def test_call_tool_treats_a_form_and_url_mix_as_the_connect_flow():
279
+ """A URL-mode request anywhere in the result keeps today's connect refusal,
280
+ even alongside a form-mode one: the connect flow must behave exactly as
281
+ before, so its presence, not the absence of other modes, decides the
282
+ outcome. The form's own message still rides along as an extra relayed
283
+ line, same as any other request that is not the chosen connect URL."""
284
+ form = types.ElicitRequest(
285
+ params=types.ElicitRequestFormParams(
286
+ message="Pick one.", requested_schema=types.ElicitRequestedSchema(properties={})
287
+ )
288
+ )
289
+ upstream, _ = _upstream_returning(
290
+ _input_required(form, _url_request("https://factory.example/my-profile/developer"))
291
+ )
292
+
265
293
  result = anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
266
294
 
267
295
  assert result.is_error
268
296
  assert _texts(result) == [
269
297
  "Pick one.",
270
- "[prd] This factory needs input the router cannot relay. Start at https://factory.example.",
298
+ "Connect your Databricks account.",
299
+ "[prd] Connect at https://factory.example/my-profile/developer, then retry.",
271
300
  ]
272
301
 
273
302
 
@@ -345,7 +374,9 @@ def test_a_dropped_connect_url_is_reported_on_stderr(capsys):
345
374
  anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
346
375
 
347
376
  stderr = capsys.readouterr().err
348
- assert "[if-cli mcp] WARN dropped a connect URL that is not on https://factory.example" in stderr
377
+ assert re.search(
378
+ r"\[if-cli mcp\] \+\d+ms WARN dropped a connect URL that is not on https://factory\.example", stderr
379
+ )
349
380
  assert "https://phish.example/connect" in stderr
350
381
 
351
382
 
@@ -1,13 +0,0 @@
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")