insightfactory-cli 1.1.2.dev30__tar.gz → 1.2.0__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 (77) hide show
  1. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/CHANGELOG.md +20 -0
  2. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/PKG-INFO +17 -1
  3. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/README.md +16 -0
  4. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/pyproject.toml +1 -1
  5. insightfactory_cli-1.2.0/src/if_cli/attribution.py +51 -0
  6. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/mcp.py +4 -1
  7. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/constants.py +1 -0
  8. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/http.py +11 -1
  9. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/oauth.py +2 -0
  10. insightfactory_cli-1.2.0/src/if_cli/router/log.py +20 -0
  11. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/router/server.py +24 -1
  12. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/router/upstream.py +6 -1
  13. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/conftest.py +2 -0
  14. insightfactory_cli-1.2.0/tests/test_attribution.py +160 -0
  15. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_mcp_command.py +6 -6
  16. insightfactory_cli-1.2.0/tests/test_router_log.py +23 -0
  17. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_router_upstream.py +30 -2
  18. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/uv.lock +1 -1
  19. insightfactory_cli-1.1.2.dev30/src/if_cli/router/log.py +0 -13
  20. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/.github/workflows/ci.yml +0 -0
  21. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/.github/workflows/claude.yml +0 -0
  22. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/.github/workflows/release.yml +0 -0
  23. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/.gitignore +0 -0
  24. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/.python-version +0 -0
  25. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/AGENTS.md +0 -0
  26. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/CLAUDE.md +0 -0
  27. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/LICENSE +0 -0
  28. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/__init__.py +0 -0
  29. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/__main__.py +0 -0
  30. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/assets/__init__.py +0 -0
  31. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/assets/insightfactoryai-logo.svg +0 -0
  32. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/cache.py +0 -0
  33. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/callback_page.py +0 -0
  34. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/cli.py +0 -0
  35. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/colour.py +0 -0
  36. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/__init__.py +0 -0
  37. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/api.py +0 -0
  38. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/config.py +0 -0
  39. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/login.py +0 -0
  40. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/logout.py +0 -0
  41. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/profiles.py +0 -0
  42. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/set_token.py +0 -0
  43. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/commands/token.py +0 -0
  44. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/config.py +0 -0
  45. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/main.py +0 -0
  46. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/router/__init__.py +0 -0
  47. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/router/catalog.py +0 -0
  48. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/router/content.py +0 -0
  49. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/router/policy.py +0 -0
  50. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/src/if_cli/runtime.py +0 -0
  51. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/__init__.py +0 -0
  52. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/cache_writer.py +0 -0
  53. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/helpers.py +0 -0
  54. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/servers.py +0 -0
  55. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_api.py +0 -0
  56. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_api_command.py +0 -0
  57. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_cache.py +0 -0
  58. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_cli.py +0 -0
  59. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_config.py +0 -0
  60. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_config_command.py +0 -0
  61. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_login.py +0 -0
  62. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_oauth.py +0 -0
  63. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_oauth_flow.py +0 -0
  64. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_oauth_force_refresh.py +0 -0
  65. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_profiles.py +0 -0
  66. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_programmatic_api.py +0 -0
  67. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_release_tools.py +0 -0
  68. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_router_catalog.py +0 -0
  69. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_router_content.py +0 -0
  70. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_router_policy.py +0 -0
  71. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_router_resources.py +0 -0
  72. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_router_server.py +0 -0
  73. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_runtime.py +0 -0
  74. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_set_token.py +0 -0
  75. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tests/test_token.py +0 -0
  76. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tools/check_release.py +0 -0
  77. {insightfactory_cli-1.1.2.dev30 → insightfactory_cli-1.2.0}/tools/release_notes.py +0 -0
@@ -11,6 +11,20 @@ that: every PR into `develop` must add an entry here, and a tag whose version ha
11
11
  section fails rather than releasing an empty page. The version only moves when the
12
12
  last one has been released, so a cycle's PRs share a section.
13
13
 
14
+ ## 1.2.0
15
+
16
+ ### Added
17
+
18
+ - Factory requests carry an `X-IF-UA` header of RFC 9110 product tokens, so the
19
+ Interfaces API can attribute work to the client that made it. The value is
20
+ `if-cli/<version>`, with the contents of `INSIGHTFACTORY_UA` in front when that
21
+ variable holds valid product tokens, for example
22
+ `if-fde-harness/5.10.0 if-cli/1.2.0`. The CLI drops a value that fails the
23
+ grammar or exceeds 256 characters and sends its own token alone. `api`, OAuth
24
+ discovery and the `if-cli mcp` router's calls to each factory send the header.
25
+ Token requests to the identity provider do not, and a header the caller sets
26
+ itself replaces the built one.
27
+
14
28
  ## 1.1.2
15
29
 
16
30
  ### Added
@@ -31,6 +45,12 @@ last one has been released, so a cycle's PRs share a section.
31
45
 
32
46
  ### Changed
33
47
 
48
+ - The router's stderr lines now carry the elapsed time since startup, and it logs when
49
+ the catalogue build starts and when it answers its first `initialize`, so an operator
50
+ can tell a slow process apart from a slow factory and a client that called before the
51
+ handshake finished. `if-cli mcp` also now logs a `starting on Python <version>` line
52
+ before doing anything else, so a process that dies during argument parsing, config
53
+ load, or profile lookup still leaves one line on stderr instead of none.
34
54
  - `release-guard` asks every PR into `develop` for a `CHANGELOG.md` entry, and asks
35
55
  for a version bump only when `develop` is still on the version `main` is on. One
36
56
  bump opens a release cycle and the PRs after it add to the section it opened,
@@ -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
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
@@ -104,6 +104,14 @@ accepted and completed the request after `if-cli` gave up, so re-issuing a
104
104
  timeout message tells you to confirm the current state before retrying rather
105
105
  than repeat the call.
106
106
 
107
+ Every request to the factory carries an `X-IF-UA` header naming the client,
108
+ `if-cli/<version>` by default. A tool that runs `if-cli` can name itself first
109
+ by setting `INSIGHTFACTORY_UA` to one or more RFC 9110 product tokens, such as
110
+ `if-fde-harness/5.10.0`, and the header becomes
111
+ `if-fde-harness/5.10.0 if-cli/<version>`. The CLI ignores a value that is not
112
+ valid product-token syntax or is longer than 256 characters. Token requests to
113
+ the identity provider never carry the header.
114
+
107
115
  Override the config directory with `INSIGHTFACTORY_CONFIG_DIR`, which is useful
108
116
  in tests and CI. The default is `~/.insightfactory`.
109
117
 
@@ -241,6 +249,14 @@ uv tool install 'insightfactory-cli[mcp]'
241
249
  uvx --from 'insightfactory-cli[mcp]' if-cli mcp foundry
242
250
  ```
243
251
 
252
+ The first `uvx` launch resolves and downloads the package from PyPI before it runs, which
253
+ added roughly four to six seconds on top of the usual startup in local testing, against
254
+ under two seconds once `uv`'s cache was warm. A client with a fixed handshake budget
255
+ (Claude Code allows 30 seconds) usually has room for that, but a slow network can close
256
+ the gap. Two ways to avoid paying it on every launch: `uv tool install 'insightfactory-cli[mcp]'`
257
+ puts a real script on `PATH` with no resolve step, or run the same `uvx --from ...` command
258
+ by hand once before pointing a client at it, which warms `uv`'s cache for the launches after.
259
+
244
260
  Add it to `.mcp.json`:
245
261
 
246
262
  ```json
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.1.2.dev30"
3
+ version = "1.2.0"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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()
@@ -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")
@@ -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
@@ -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
@@ -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()
@@ -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).
@@ -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,
@@ -7,5 +7,7 @@ import pytest
7
7
  def isolate_insightfactory_home(tmp_path, monkeypatch):
8
8
  monkeypatch.setenv("INSIGHTFACTORY_CONFIG_DIR", str(tmp_path))
9
9
  monkeypatch.delenv("INSIGHTFACTORY_CONFIG_PROFILE", raising=False)
10
+ # The FDE harness sets this in the shells it runs, tests included.
11
+ monkeypatch.delenv("INSIGHTFACTORY_UA", raising=False)
10
12
  monkeypatch.setenv("NO_COLOR", "1")
11
13
  return tmp_path
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+
5
+ import pytest
6
+
7
+ from if_cli import oauth
8
+ from if_cli.attribution import MAX_CALLER_PRODUCTS_LENGTH, build_x_if_ua
9
+ from if_cli.cache import store_tokens
10
+ from if_cli.config import Profile
11
+ from if_cli.http import fetch_with_timeout
12
+ from tests.servers import Request, Response, ThreadedServer
13
+
14
+ OWN = f"if-cli/{version('insightfactory-cli')}"
15
+
16
+
17
+ def _header(headers: dict[str, str], name: str = "X-IF-UA") -> str | None:
18
+ matches = [value for key, value in headers.items() if key.lower() == name.lower()]
19
+ assert len(matches) <= 1, f"{name} sent more than once: {matches}"
20
+ return matches[0] if matches else None
21
+
22
+
23
+ def _recording_server(response: Response | None = None) -> tuple[ThreadedServer, list[dict[str, str]]]:
24
+ seen: list[dict[str, str]] = []
25
+
26
+ def handler(request: Request) -> Response:
27
+ seen.append(request.headers)
28
+ return response or Response(body={"ok": True})
29
+
30
+ return ThreadedServer(handler).start(), seen
31
+
32
+
33
+ def _profile(host: str) -> Profile:
34
+ return {
35
+ "name": "a",
36
+ "host": host,
37
+ "audience": f"{host}/api",
38
+ "callback_port": 0,
39
+ "client_id": None,
40
+ "organization": None,
41
+ }
42
+
43
+
44
+ def test_unset_sends_only_the_cli_token():
45
+ assert build_x_if_ua() == OWN
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ "caller",
50
+ [
51
+ "if-fde-harness/5.10.0",
52
+ "if-fde-harness-mcp",
53
+ "if-fde-harness/5.10.0 other/1",
54
+ "a!#$%&'*+-.^_`|~z/9",
55
+ ],
56
+ )
57
+ def test_a_valid_value_goes_first(monkeypatch, caller: str):
58
+ monkeypatch.setenv("INSIGHTFACTORY_UA", caller)
59
+ assert build_x_if_ua() == f"{caller} {OWN}"
60
+
61
+
62
+ @pytest.mark.parametrize(
63
+ "caller",
64
+ [
65
+ "",
66
+ " ",
67
+ "\t",
68
+ "if-fde-harness/5.10.0\nX-Injected: 1",
69
+ "if-fde-harness/5.10.0\r\n",
70
+ "if-fde-harness/5.10.0\n",
71
+ "$(whoami)",
72
+ "if-fde-harness/$(whoami)",
73
+ " if-fde-harness/5.10.0",
74
+ "if-fde-harness/5.10.0 ",
75
+ "if-fde-harness/5.10.0 other/1",
76
+ "if-fde-harness/",
77
+ "/5.10.0",
78
+ "a/b/c",
79
+ "if-fde-harness/5.10.0 (comment)",
80
+ "harnessé/1",
81
+ "x" * (MAX_CALLER_PRODUCTS_LENGTH + 1),
82
+ ],
83
+ )
84
+ def test_an_invalid_value_is_dropped(monkeypatch, caller: str):
85
+ monkeypatch.setenv("INSIGHTFACTORY_UA", caller)
86
+ assert build_x_if_ua() == OWN
87
+
88
+
89
+ def test_a_value_at_the_cap_is_kept(monkeypatch):
90
+ caller = "x" * MAX_CALLER_PRODUCTS_LENGTH
91
+ monkeypatch.setenv("INSIGHTFACTORY_UA", caller)
92
+ assert build_x_if_ua() == f"{caller} {OWN}"
93
+
94
+
95
+ def test_fetch_with_timeout_sends_the_header(monkeypatch):
96
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0")
97
+ server, seen = _recording_server()
98
+ try:
99
+ fetch_with_timeout(f"{server.origin}/api/health")
100
+ finally:
101
+ server.close()
102
+ assert _header(seen[0]) == f"if-fde-harness/5.10.0 {OWN}"
103
+
104
+
105
+ def test_fetch_with_timeout_never_puts_an_invalid_value_on_the_wire(monkeypatch):
106
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0\r\nX-Injected: 1")
107
+ server, seen = _recording_server()
108
+ try:
109
+ fetch_with_timeout(f"{server.origin}/api/health")
110
+ finally:
111
+ server.close()
112
+ assert _header(seen[0]) == OWN
113
+ assert _header(seen[0], "X-Injected") is None
114
+
115
+
116
+ @pytest.mark.parametrize("name", ["X-IF-UA", "x-if-ua"])
117
+ def test_a_caller_supplied_header_wins(monkeypatch, name: str):
118
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0")
119
+ server, seen = _recording_server()
120
+ try:
121
+ fetch_with_timeout(f"{server.origin}/api/health", headers={name: "custom/1"})
122
+ finally:
123
+ server.close()
124
+ assert _header(seen[0]) == "custom/1"
125
+
126
+
127
+ def test_oauth_discovery_on_the_factory_carries_the_header(monkeypatch):
128
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0")
129
+ server, seen = _recording_server(
130
+ Response(
131
+ body={
132
+ "authorization_endpoint": "https://factory.example/authorize",
133
+ "token_endpoint": "https://factory.example/oauth/token",
134
+ }
135
+ )
136
+ )
137
+ try:
138
+ oauth._discover(server.origin)
139
+ finally:
140
+ server.close()
141
+ assert _header(seen[0]) == f"if-fde-harness/5.10.0 {OWN}"
142
+
143
+
144
+ def test_the_identity_provider_token_request_carries_no_header(monkeypatch):
145
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness/5.10.0")
146
+ idp, seen = _recording_server(Response(body={"access_token": "refreshed-token", "expires_in": 3600}))
147
+ try:
148
+ profile = _profile("https://factory.example")
149
+ store_tokens(
150
+ profile["host"],
151
+ {"access_token": "stale-token", "refresh_token": "refresh-tok", "expires_in": 0},
152
+ f"{idp.origin}/token",
153
+ "test-client",
154
+ replace=True,
155
+ )
156
+ assert oauth.get_valid_token(profile, force_refresh=True) == "refreshed-token"
157
+ finally:
158
+ idp.close()
159
+ assert len(seen) == 1
160
+ assert _header(seen[0]) is None
@@ -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,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
+ import re
5
+ from importlib.metadata import version
4
6
  from typing import Any
5
7
 
6
8
  import pytest
@@ -14,7 +16,7 @@ import mcp_types as types # noqa: E402
14
16
  from if_cli.cache import store_tokens # noqa: E402
15
17
  from if_cli.config import Profile # noqa: E402
16
18
  from if_cli.router.upstream import ConnectUrlRecorder, ProfileBearerAuth, Upstream # noqa: E402
17
- from tests.servers import Response, ThreadedServer # noqa: E402
19
+ from tests.servers import Request, Response, ThreadedServer # noqa: E402
18
20
 
19
21
 
20
22
  def _profile(host: str) -> Profile:
@@ -84,6 +86,30 @@ def test_auth_remints_the_token_once_on_a_401_and_retries():
84
86
  idp.close()
85
87
 
86
88
 
89
+ def test_the_upstream_client_names_the_router_and_the_cli(monkeypatch):
90
+ """The router runs with its MCP registration env, so the harness token goes first."""
91
+ monkeypatch.setenv("INSIGHTFACTORY_UA", "if-fde-harness-mcp")
92
+ seen: list[dict[str, str]] = []
93
+
94
+ def handler(request: Request) -> Response:
95
+ seen.append(request.headers)
96
+ # Any error ends the session; only the first request's headers matter.
97
+ return Response(status=500, body="")
98
+
99
+ server = ThreadedServer(handler).start()
100
+ try:
101
+ profile = _profile(server.origin)
102
+ store_tokens(profile["host"], {"access_token": "test-token", "expires_in": 3600}, "", "c", replace=True)
103
+ with pytest.raises(Exception):
104
+ anyio.run(Upstream("prd", profile, timeout=5).list_tools)
105
+ finally:
106
+ server.close()
107
+ assert seen, "the router never reached the factory"
108
+ headers = {name.lower(): value for name, value in seen[0].items()}
109
+ assert headers["x-if-ua"] == f"if-fde-harness-mcp if-cli/{version('insightfactory-cli')}"
110
+ assert headers["authorization"] == "Bearer test-token"
111
+
112
+
87
113
  def _url_request(url: str, message: str = "Connect your Databricks account.") -> types.ElicitRequest:
88
114
  return types.ElicitRequest(params=types.ElicitRequestURLParams(message=message, url=url))
89
115
 
@@ -373,7 +399,9 @@ def test_a_dropped_connect_url_is_reported_on_stderr(capsys):
373
399
  anyio.run(lambda: upstream.call_tool("run_sql_query", {}))
374
400
 
375
401
  stderr = capsys.readouterr().err
376
- assert "[if-cli mcp] WARN dropped a connect URL that is not on https://factory.example" in stderr
402
+ assert re.search(
403
+ r"\[if-cli mcp\] \+\d+ms WARN dropped a connect URL that is not on https://factory\.example", stderr
404
+ )
377
405
  assert "https://phish.example/connect" in stderr
378
406
 
379
407
 
@@ -305,7 +305,7 @@ wheels = [
305
305
 
306
306
  [[package]]
307
307
  name = "insightfactory-cli"
308
- version = "1.1.2"
308
+ version = "1.2.0"
309
309
  source = { editable = "." }
310
310
 
311
311
  [package.optional-dependencies]
@@ -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")