insightfactory-cli 1.1.2.dev29__py3-none-any.whl → 1.1.2.dev31__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 +4 -1
- if_cli/router/log.py +9 -2
- if_cli/router/server.py +24 -1
- if_cli/router/upstream.py +44 -1
- {insightfactory_cli-1.1.2.dev29.dist-info → insightfactory_cli-1.1.2.dev31.dist-info}/METADATA +9 -1
- {insightfactory_cli-1.1.2.dev29.dist-info → insightfactory_cli-1.1.2.dev31.dist-info}/RECORD +9 -9
- {insightfactory_cli-1.1.2.dev29.dist-info → insightfactory_cli-1.1.2.dev31.dist-info}/WHEEL +1 -1
- {insightfactory_cli-1.1.2.dev29.dist-info → insightfactory_cli-1.1.2.dev31.dist-info}/entry_points.txt +0 -0
- {insightfactory_cli-1.1.2.dev29.dist-info → insightfactory_cli-1.1.2.dev31.dist-info}/licenses/LICENSE +0 -0
if_cli/commands/mcp.py
CHANGED
|
@@ -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")
|
if_cli/router/log.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
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()
|
|
4
10
|
|
|
5
11
|
|
|
6
12
|
def log(message: str) -> None:
|
|
7
|
-
"""Write one diagnostic line to stderr.
|
|
13
|
+
"""Write one timestamped diagnostic line to stderr.
|
|
8
14
|
|
|
9
15
|
The router speaks MCP over stdout, so stderr is the only channel that can
|
|
10
16
|
carry anything to the operator. This lives in its own module because both
|
|
11
17
|
`server` and `upstream` write to it and `server` imports `upstream`.
|
|
12
18
|
"""
|
|
13
|
-
|
|
19
|
+
elapsed_ms = round((time.monotonic() - _START) * 1000)
|
|
20
|
+
sys.stderr.write(f"[if-cli mcp] +{elapsed_ms}ms {message}\n")
|
if_cli/router/server.py
CHANGED
|
@@ -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).
|
if_cli/router/upstream.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
{insightfactory_cli-1.1.2.dev29.dist-info → insightfactory_cli-1.1.2.dev31.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: insightfactory-cli
|
|
3
|
-
Version: 1.1.2.
|
|
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
|
{insightfactory_cli-1.1.2.dev29.dist-info → insightfactory_cli-1.1.2.dev31.dist-info}/RECORD
RENAMED
|
@@ -17,19 +17,19 @@ 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=
|
|
20
|
+
if_cli/commands/mcp.py,sha256=iaSsqWkV1lnFjIw9y1pX1ORB1MdKiOVWgK23EDIsDB4,7342
|
|
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
26
|
if_cli/router/content.py,sha256=POL2n5NIY8yenQHJe8pUbTuTNpPSUSm0Y6Txbvehzhs,14487
|
|
27
|
-
if_cli/router/log.py,sha256=
|
|
27
|
+
if_cli/router/log.py,sha256=wtaqsFcD3hPJRu4jCJaDMSedj-43Y07ym62YgrOm1yM,740
|
|
28
28
|
if_cli/router/policy.py,sha256=CclbjuOA5mwwoQiNDa17iQeN9eXRbfy11bS9JFd8gL4,2840
|
|
29
|
-
if_cli/router/server.py,sha256=
|
|
30
|
-
if_cli/router/upstream.py,sha256=
|
|
31
|
-
insightfactory_cli-1.1.2.
|
|
32
|
-
insightfactory_cli-1.1.2.
|
|
33
|
-
insightfactory_cli-1.1.2.
|
|
34
|
-
insightfactory_cli-1.1.2.
|
|
35
|
-
insightfactory_cli-1.1.2.
|
|
29
|
+
if_cli/router/server.py,sha256=yCjGAm-tnZBOTg5AfvojnLcKcZLEbQQeT9iqqX8XV5A,27397
|
|
30
|
+
if_cli/router/upstream.py,sha256=Ga0OM918Y1CFyQFGs2wO0ocyjcMe1Wk0LvffOZOaE34,18559
|
|
31
|
+
insightfactory_cli-1.1.2.dev31.dist-info/METADATA,sha256=LkShsicB2QHUEjaS9FmsW9T2dPlGIl92Ew-OMsSCg28,24331
|
|
32
|
+
insightfactory_cli-1.1.2.dev31.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
33
|
+
insightfactory_cli-1.1.2.dev31.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
|
|
34
|
+
insightfactory_cli-1.1.2.dev31.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
|
|
35
|
+
insightfactory_cli-1.1.2.dev31.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|