insightfactory-cli 1.1.2.dev30__py3-none-any.whl → 1.2.0.dev34__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/attribution.py ADDED
@@ -0,0 +1,51 @@
1
+ """The `X-IF-UA` client-attribution header.
2
+
3
+ The Interfaces API attributes work to the client that made the request by reading
4
+ `X-IF-UA`, a list of RFC 9110 product tokens. It takes the first token, splits it at
5
+ `/` into client and version, and ignores the rest. So a tool that drives `if-cli`
6
+ names itself first through `INSIGHTFACTORY_UA` and `if-cli` appends its own token:
7
+
8
+ X-IF-UA: if-cli/1.2.0 # INSIGHTFACTORY_UA unset
9
+ X-IF-UA: if-fde-harness/5.10.0 if-cli/1.2.0 # INSIGHTFACTORY_UA=if-fde-harness/5.10.0
10
+
11
+ The environment value is only ever sent after it matches the product-token grammar,
12
+ so a stray newline or shell fragment in it cannot add a header or reach the factory.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from functools import cache
20
+ from importlib.metadata import version
21
+
22
+ from if_cli.constants import ENV_UA
23
+
24
+ HEADER_NAME = "X-IF-UA"
25
+ MAX_CALLER_PRODUCTS_LENGTH = 256
26
+
27
+ # RFC 9110 section 5.6.2: token = 1*tchar. Section 10.1.5: product = token ["/" product-version].
28
+ _TCHAR = r"[!#$%&'*+\-.^_`|~0-9A-Za-z]"
29
+ _PRODUCT = rf"{_TCHAR}+(?:/{_TCHAR}+)?"
30
+ _PRODUCTS = re.compile(rf"{_PRODUCT}(?: {_PRODUCT})*")
31
+
32
+
33
+ @cache
34
+ def _own_product() -> str:
35
+ return f"if-cli/{version('insightfactory-cli')}"
36
+
37
+
38
+ def is_valid_products(value: str) -> bool:
39
+ """Whether `value` is one or more space-separated product tokens, within the length cap."""
40
+ return len(value) <= MAX_CALLER_PRODUCTS_LENGTH and _PRODUCTS.fullmatch(value) is not None
41
+
42
+
43
+ def build_x_if_ua() -> str:
44
+ """The `X-IF-UA` value: the caller's products from `INSIGHTFACTORY_UA`, then `if-cli`'s own.
45
+
46
+ An unset, empty or invalid `INSIGHTFACTORY_UA` leaves `if-cli`'s token alone.
47
+ """
48
+ caller = os.environ.get(ENV_UA)
49
+ if caller and is_valid_products(caller):
50
+ return f"{caller} {_own_product()}"
51
+ return _own_product()
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/constants.py CHANGED
@@ -1,5 +1,6 @@
1
1
  ENV_PROFILE: str = "INSIGHTFACTORY_CONFIG_PROFILE"
2
2
  ENV_REQUEST_TIMEOUT: str = "INSIGHTFACTORY_REQUEST_TIMEOUT"
3
+ ENV_UA: str = "INSIGHTFACTORY_UA"
3
4
  DEFAULT_SCOPE: str = "openid profile email offline_access"
4
5
  LOOPBACK_HOST: str = "127.0.0.1"
5
6
  DEFAULT_CALLBACK_PORT: int = 53682
if_cli/http.py CHANGED
@@ -10,6 +10,7 @@ from email.message import Message
10
10
  from typing import NoReturn
11
11
  from urllib.parse import ParseResult, urljoin, urlparse
12
12
 
13
+ from if_cli.attribution import HEADER_NAME, build_x_if_ua
13
14
  from if_cli.constants import ENV_REQUEST_TIMEOUT
14
15
  from if_cli.runtime import die
15
16
 
@@ -170,6 +171,7 @@ def fetch_with_timeout(
170
171
  body: str | bytes | None = None,
171
172
  timeout: float | None = None,
172
173
  warn_unsafe_retry: bool = False,
174
+ attribute: bool = True,
173
175
  ) -> HttpResponse:
174
176
  """Issue one request, dying on timeout.
175
177
 
@@ -177,7 +179,15 @@ def fetch_with_timeout(
177
179
  sense for a request the caller issued on the user's behalf against the factory.
178
180
  It is wrong advice for an OAuth round trip, where retrying is the correct response
179
181
  and there is no factory-side state to confirm.
182
+
183
+ `attribute` adds the `X-IF-UA` header (see `if_cli.attribution`) unless the
184
+ caller already set one. It is on for factory requests and off for the token
185
+ endpoint, which belongs to the identity provider: that is a third party, and the
186
+ header is for the factory's own client metrics.
180
187
  """
188
+ request_headers = dict(headers or {})
189
+ if attribute and not any(name.lower() == HEADER_NAME.lower() for name in request_headers):
190
+ request_headers[HEADER_NAME] = build_x_if_ua()
181
191
  data: bytes | None
182
192
  if body is None:
183
193
  data = None
@@ -185,7 +195,7 @@ def fetch_with_timeout(
185
195
  data = body
186
196
  else:
187
197
  data = body.encode("utf-8")
188
- request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
198
+ request = urllib.request.Request(url, data=data, headers=request_headers, method=method)
189
199
  effective_timeout = resolve_request_timeout(timeout)
190
200
  try:
191
201
  # Callers pass endpoints validated by parse_http_url or derived from a
if_cli/oauth.py CHANGED
@@ -128,6 +128,8 @@ def _token_request(token_endpoint: str, form: dict[str, str]) -> dict[str, objec
128
128
  headers={"Content-Type": "application/x-www-form-urlencoded"},
129
129
  body=urlencode(form),
130
130
  timeout=DEFAULT_REQUEST_TIMEOUT_S,
131
+ # The token endpoint is the identity provider's, not the factory's.
132
+ attribute=False,
131
133
  )
132
134
  if not response.ok:
133
135
  body = response.text()
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
- sys.stderr.write(f"[if-cli mcp] {message}\n")
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
@@ -9,6 +9,7 @@ import mcp_types as types
9
9
  from mcp.client import Client
10
10
  from mcp.client.streamable_http import streamable_http_client
11
11
 
12
+ from if_cli.attribution import HEADER_NAME, build_x_if_ua
12
13
  from if_cli.config import Profile
13
14
  from if_cli.http import parse_url, url_origin
14
15
  from if_cli.oauth import get_valid_token
@@ -200,7 +201,11 @@ class Upstream:
200
201
  async def _run(self, action, *, elicitation_callback=None):
201
202
  url = f"{self.profile['host']}/mcp"
202
203
  try:
203
- async with httpx2.AsyncClient(auth=ProfileBearerAuth(self.profile), timeout=self.timeout) as http_client:
204
+ async with httpx2.AsyncClient(
205
+ auth=ProfileBearerAuth(self.profile),
206
+ timeout=self.timeout,
207
+ headers={HEADER_NAME: build_x_if_ua()},
208
+ ) as http_client:
204
209
  async with Client(
205
210
  streamable_http_client(url, http_client=http_client),
206
211
  elicitation_callback=elicitation_callback,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.1.2.dev30
3
+ Version: 1.2.0.dev34
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>
@@ -128,6 +128,14 @@ accepted and completed the request after `if-cli` gave up, so re-issuing a
128
128
  timeout message tells you to confirm the current state before retrying rather
129
129
  than repeat the call.
130
130
 
131
+ Every request to the factory carries an `X-IF-UA` header naming the client,
132
+ `if-cli/<version>` by default. A tool that runs `if-cli` can name itself first
133
+ by setting `INSIGHTFACTORY_UA` to one or more RFC 9110 product tokens, such as
134
+ `if-fde-harness/5.10.0`, and the header becomes
135
+ `if-fde-harness/5.10.0 if-cli/<version>`. The CLI ignores a value that is not
136
+ valid product-token syntax or is longer than 256 characters. Token requests to
137
+ the identity provider never carry the header.
138
+
131
139
  Override the config directory with `INSIGHTFACTORY_CONFIG_DIR`, which is useful
132
140
  in tests and CI. The default is `~/.insightfactory`.
133
141
 
@@ -265,6 +273,14 @@ uv tool install 'insightfactory-cli[mcp]'
265
273
  uvx --from 'insightfactory-cli[mcp]' if-cli mcp foundry
266
274
  ```
267
275
 
276
+ The first `uvx` launch resolves and downloads the package from PyPI before it runs, which
277
+ added roughly four to six seconds on top of the usual startup in local testing, against
278
+ under two seconds once `uv`'s cache was warm. A client with a fixed handshake budget
279
+ (Claude Code allows 30 seconds) usually has room for that, but a slow network can close
280
+ the gap. Two ways to avoid paying it on every launch: `uv tool install 'insightfactory-cli[mcp]'`
281
+ puts a real script on `PATH` with no resolve step, or run the same `uvx --from ...` command
282
+ by hand once before pointing a client at it, which warms `uv`'s cache for the launches after.
283
+
268
284
  Add it to `.mcp.json`:
269
285
 
270
286
  ```json
@@ -1,14 +1,15 @@
1
1
  if_cli/__init__.py,sha256=YYcz-hEiXXUjdBFGipOrVHynsyH4ZLqQOCDobvCAu8Y,26
2
2
  if_cli/__main__.py,sha256=2hLFIiT-gocMb0VJVzVYtGVIoHrxyMChucRgexbXLSQ,68
3
+ if_cli/attribution.py,sha256=hNp1hBueRUh1EKYs61AZF76voehw70F7gZKiI5zOMCw,1869
3
4
  if_cli/cache.py,sha256=UfvHs3GQIVg0sVfKYWw4UGlq8d-8BEXuZIDfC1hwwjA,6587
4
5
  if_cli/callback_page.py,sha256=lTHd3-vzlT9ndAL7XYonBfy97lBW7apRw3V7SmhNObo,5907
5
6
  if_cli/cli.py,sha256=fF7HvsUgpKhkLFdXLMJ1JXHGjp3RwJt2YmPSEW0d8gY,6270
6
7
  if_cli/colour.py,sha256=2hbaM4ubTHGja6YZRD886eqnB7T2IaZyW5rvfOjxe4M,682
7
8
  if_cli/config.py,sha256=QiphIhVLDN3sp39xMWKwXEyJWxx4IhXT28oEtmPc0ew,10601
8
- if_cli/constants.py,sha256=RW43KupAdhXfBsX6MxzZdJ-R57brX0q0TgIq8xXgvPs,269
9
- if_cli/http.py,sha256=W8HL3uZLeOu5MgmsOavM1Y6x4AcrInIQsAr5hx-j8AE,7671
9
+ if_cli/constants.py,sha256=WxZozWfhMCFWU_Wupa__CaJI9UgqJ28W8cchGtp6p4Q,303
10
+ if_cli/http.py,sha256=LNkQtyUR1DAgLcR2CrNzTMHFQO_j_vfHuOBagmQEYNk,8249
10
11
  if_cli/main.py,sha256=pn-X2wrHNplc8ceT5_nEv5XlrTN3KgpK8UU4gAmBiWY,3212
11
- if_cli/oauth.py,sha256=8QZy4A7NqnWSnTULQct-Tj6RT906RstBJOloSTzgflU,16462
12
+ if_cli/oauth.py,sha256=lSJXzOsOzHlkZX56JWoh9G6-sD9MOGgmwpI18tdhA6c,16563
12
13
  if_cli/runtime.py,sha256=Pz2iVclqy-uu1UMQWK2P_Hx7O4zAF30UpSFqQvGN0NY,2663
13
14
  if_cli/assets/__init__.py,sha256=2vBBor8lUEQofiC6EvS5BvF_ANhfK8bfR9S0OXI8j3g,41
14
15
  if_cli/assets/insightfactoryai-logo.svg,sha256=yFxHkoHl6VNm8ppCInTZCVN_bdOog9h-NV8R4Gw1Qew,8192
@@ -17,19 +18,19 @@ if_cli/commands/api.py,sha256=XeVhhD39Oho4xNbMJIGh1I6vi53on5EGGsAtsb_5TAg,9016
17
18
  if_cli/commands/config.py,sha256=1KAxjme897NIbXTGBw1rNJlXnFvhbEUf5MAli8A93ns,1724
18
19
  if_cli/commands/login.py,sha256=vxsYcRZWsaNEzekyN1zy4ROJO-OCGUpjaVUBhtbK1LY,3255
19
20
  if_cli/commands/logout.py,sha256=TW0P4_tXcTtiHHR73xVPHyhlbOWD1jBnkZsMImCiCBw,668
20
- if_cli/commands/mcp.py,sha256=SVF4egfA9RzjY5GHRlbk0mVi0HtmKfMh8v-SSYHjs98,7270
21
+ if_cli/commands/mcp.py,sha256=iaSsqWkV1lnFjIw9y1pX1ORB1MdKiOVWgK23EDIsDB4,7342
21
22
  if_cli/commands/profiles.py,sha256=tgZP5sMqzbFHXlfvglL2plsGdU0M3joo3wvQKX45J1U,3950
22
23
  if_cli/commands/set_token.py,sha256=BVyv_QmByvllr04pE2sz8JFEscHbIgOC-7ZPg61PRoc,1886
23
24
  if_cli/commands/token.py,sha256=CyiXkyH7vxgH91-n-XJzeHnLI6mZJwO9Js5AHvfHaeM,1120
24
25
  if_cli/router/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
26
  if_cli/router/catalog.py,sha256=Er2ZbWTwDUVU-qBGCvyA0bIlFk3UYw_6sVUlYwBhMpI,4992
26
27
  if_cli/router/content.py,sha256=POL2n5NIY8yenQHJe8pUbTuTNpPSUSm0Y6Txbvehzhs,14487
27
- if_cli/router/log.py,sha256=YLWeHUb05HUnS3XHubCwy7oLZAUEGAiHcZysz_YMAAo,412
28
+ if_cli/router/log.py,sha256=wtaqsFcD3hPJRu4jCJaDMSedj-43Y07ym62YgrOm1yM,740
28
29
  if_cli/router/policy.py,sha256=CclbjuOA5mwwoQiNDa17iQeN9eXRbfy11bS9JFd8gL4,2840
29
- if_cli/router/server.py,sha256=JZ-7xbNyDadSY1VVLHpSVTjfx6vxI_uumxFaD22Pl0Y,26449
30
- if_cli/router/upstream.py,sha256=Ga0OM918Y1CFyQFGs2wO0ocyjcMe1Wk0LvffOZOaE34,18559
31
- insightfactory_cli-1.1.2.dev30.dist-info/METADATA,sha256=slUXCeTP0H1dPYnOUVV2AZyWBDHlteeG3Grz5dED0Fc,23695
32
- insightfactory_cli-1.1.2.dev30.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
33
- insightfactory_cli-1.1.2.dev30.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
34
- insightfactory_cli-1.1.2.dev30.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
35
- insightfactory_cli-1.1.2.dev30.dist-info/RECORD,,
30
+ if_cli/router/server.py,sha256=yCjGAm-tnZBOTg5AfvojnLcKcZLEbQQeT9iqqX8XV5A,27397
31
+ if_cli/router/upstream.py,sha256=Ly4fcuQRywS6db2oDYoe8F4c9hryN75FdBqSWzA09g4,18720
32
+ insightfactory_cli-1.2.0.dev34.dist-info/METADATA,sha256=xbyLKunNzQ2g7wv-86s5ruh_-rnUyetLKVWwYINNBp8,24817
33
+ insightfactory_cli-1.2.0.dev34.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
34
+ insightfactory_cli-1.2.0.dev34.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
35
+ insightfactory_cli-1.2.0.dev34.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
36
+ insightfactory_cli-1.2.0.dev34.dist-info/RECORD,,