insightfactory-cli 1.0.3.dev17__tar.gz → 1.0.3.dev19__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 (63) hide show
  1. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/CLAUDE.md +5 -2
  2. insightfactory_cli-1.0.3.dev17/README.md → insightfactory_cli-1.0.3.dev19/PKG-INFO +106 -1
  3. insightfactory_cli-1.0.3.dev17/PKG-INFO → insightfactory_cli-1.0.3.dev19/README.md +82 -19
  4. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/pyproject.toml +5 -2
  5. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/cli.py +6 -0
  6. insightfactory_cli-1.0.3.dev19/src/if_cli/commands/mcp.py +164 -0
  7. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/commands/profiles.py +3 -1
  8. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/config.py +86 -0
  9. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/main.py +10 -0
  10. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/oauth.py +12 -5
  11. insightfactory_cli-1.0.3.dev19/src/if_cli/router/catalog.py +132 -0
  12. insightfactory_cli-1.0.3.dev19/src/if_cli/router/policy.py +86 -0
  13. insightfactory_cli-1.0.3.dev19/src/if_cli/router/server.py +285 -0
  14. insightfactory_cli-1.0.3.dev19/src/if_cli/router/upstream.py +107 -0
  15. insightfactory_cli-1.0.3.dev19/tests/__init__.py +0 -0
  16. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_cli.py +19 -0
  17. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_config.py +118 -0
  18. insightfactory_cli-1.0.3.dev19/tests/test_mcp_command.py +838 -0
  19. insightfactory_cli-1.0.3.dev19/tests/test_oauth_force_refresh.py +81 -0
  20. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_profiles.py +24 -0
  21. insightfactory_cli-1.0.3.dev19/tests/test_router_catalog.py +53 -0
  22. insightfactory_cli-1.0.3.dev19/tests/test_router_policy.py +133 -0
  23. insightfactory_cli-1.0.3.dev19/tests/test_router_server.py +13 -0
  24. insightfactory_cli-1.0.3.dev19/tests/test_router_upstream.py +80 -0
  25. insightfactory_cli-1.0.3.dev19/uv.lock +1100 -0
  26. insightfactory_cli-1.0.3.dev17/uv.lock +0 -212
  27. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/.github/workflows/ci.yml +0 -0
  28. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/.github/workflows/claude.yml +0 -0
  29. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/.github/workflows/release.yml +0 -0
  30. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/.gitignore +0 -0
  31. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/.python-version +0 -0
  32. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/AGENTS.md +0 -0
  33. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/LICENSE +0 -0
  34. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/__init__.py +0 -0
  35. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/__main__.py +0 -0
  36. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/cache.py +0 -0
  37. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/colour.py +0 -0
  38. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/commands/__init__.py +0 -0
  39. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/commands/api.py +0 -0
  40. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/commands/config.py +0 -0
  41. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/commands/login.py +0 -0
  42. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/commands/logout.py +0 -0
  43. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/commands/set_token.py +0 -0
  44. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/commands/token.py +0 -0
  45. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/constants.py +0 -0
  46. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/http.py +0 -0
  47. {insightfactory_cli-1.0.3.dev17/tests → insightfactory_cli-1.0.3.dev19/src/if_cli/router}/__init__.py +0 -0
  48. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/src/if_cli/runtime.py +0 -0
  49. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/cache_writer.py +0 -0
  50. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/conftest.py +0 -0
  51. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/helpers.py +0 -0
  52. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/servers.py +0 -0
  53. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_api.py +0 -0
  54. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_api_command.py +0 -0
  55. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_cache.py +0 -0
  56. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_config_command.py +0 -0
  57. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_login.py +0 -0
  58. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_oauth.py +0 -0
  59. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_oauth_flow.py +0 -0
  60. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_programmatic_api.py +0 -0
  61. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_runtime.py +0 -0
  62. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_set_token.py +0 -0
  63. {insightfactory_cli-1.0.3.dev17 → insightfactory_cli-1.0.3.dev19}/tests/test_token.py +0 -0
@@ -16,11 +16,14 @@ uv run ty check
16
16
  ## Conventions
17
17
 
18
18
  - Keep one module per CLI command under `src/if_cli/commands/`.
19
+ - The base install has no runtime dependencies. Optional features live in
20
+ extras and are imported lazily inside their command module.
19
21
  - Never send a profile bearer token outside that profile's factory origin.
20
22
  - OAuth uses authorization-code flow with PKCE and a loopback-only callback.
21
23
  - Store profiles and tokens through the atomic private-file helpers.
22
- - On-disk profile/token-store paths and JSON shapes stay compatible with the
23
- Node CLI (`~/.insightfactory`, `INSIGHTFACTORY_CONFIG_DIR` override).
24
+ - The Node CLI is retired; `[factory <name>]` sections are Python-only. Profile
25
+ sections and the token store keep the Node layout (`~/.insightfactory`,
26
+ `INSIGHTFACTORY_CONFIG_DIR` override).
24
27
  - Do not commit credentials, access tokens, refresh tokens, or client secrets.
25
28
  - Test fixtures use only neutral hostnames (`factory.example`).
26
29
  - Do not publish to PyPI from a developer machine. Publishing happens in CI: the
@@ -1,3 +1,27 @@
1
+ Metadata-Version: 2.5
2
+ Name: insightfactory-cli
3
+ Version: 1.0.3.dev19
4
+ Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
+ Project-URL: Homepage, https://insightfactory.ai
6
+ Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
7
+ License-Expression: LicenseRef-Proprietary
8
+ License-File: LICENSE
9
+ Keywords: cli,insightfactory,oauth,pkce
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: mcp
18
+ Requires-Dist: anyio; extra == 'mcp'
19
+ Requires-Dist: exceptiongroup; (python_version < '3.11') and extra == 'mcp'
20
+ Requires-Dist: httpx2; extra == 'mcp'
21
+ Requires-Dist: mcp-types; extra == 'mcp'
22
+ Requires-Dist: mcp<3,>=2.1; extra == 'mcp'
23
+ Description-Content-Type: text/markdown
24
+
1
25
  # insightfactory-cli
2
26
 
3
27
  Profile-based authentication CLI for the InsightFactory Interfaces API. It
@@ -183,6 +207,15 @@ write to stdout, never launch a browser or block on interactive input, and only
183
207
  use bounded waits. Changing or removing any of them is a breaking change and
184
208
  needs a major version bump.
185
209
 
210
+ `get_valid_token` takes a keyword-only `force_refresh` (default `False`). When
211
+ `True`, it refreshes the cached token even if it is still fresh, which a
212
+ caller that just received a 401 from the factory needs: the cached token was
213
+ fresh a moment ago but the factory has since rejected it. With a refresh
214
+ token cached, this re-mints regardless of freshness; without one — a token
215
+ pasted through `if-cli set-token` — it raises `CliError` with the same
216
+ `if-cli login -p <name>` hint rather than serving the token that was just
217
+ rejected.
218
+
186
219
  ## How authentication works
187
220
 
188
221
  - Profiles live in `~/.insightfactory/config`, one per customer and environment.
@@ -201,6 +234,76 @@ needs a major version bump.
201
234
  the client ID from the factory's discovery document at runtime and holds no
202
235
  client secret.
203
236
 
237
+ ## MCP router (`if-cli mcp`)
238
+
239
+ Runs one MCP server over stdio that fronts several factory environments as a
240
+ single tool catalogue, instead of one MCP connection per environment. Every
241
+ tool gets a required `environment` argument and each call is forwarded to
242
+ that environment's own factory `/mcp` endpoint, authenticated with that
243
+ environment's `if-cli` profile — a profile's bearer token never reaches a
244
+ different environment's host. A tool is only offered on the environments
245
+ whose input schema matches the published one, so a factory on an older
246
+ release cannot be handed argument shapes it does not accept.
247
+
248
+ It needs the `mcp` extra, which is not part of the base install. Name its environments once
249
+ in a `[factory <name>]` section of `~/.insightfactory/config`, alongside profiles:
250
+
251
+ ```ini
252
+ [factory foundry]
253
+ dev = example-dev
254
+ tst = example-tst
255
+ writable = dev
256
+ ```
257
+
258
+ Every key except `writable` and `catalog` is `<environment code> = <profile name>`; file
259
+ order is the environment order. `writable` is a comma-separated list of codes (default
260
+ none); `catalog` is one code (default the first environment). Then:
261
+
262
+ ```bash
263
+ uv tool install 'insightfactory-cli[mcp]'
264
+ uvx --from 'insightfactory-cli[mcp]' if-cli mcp foundry
265
+ ```
266
+
267
+ Add it to `.mcp.json`:
268
+
269
+ ```json
270
+ {
271
+ "mcpServers": {
272
+ "if-factory": {
273
+ "command": "uvx",
274
+ "args": ["--from", "insightfactory-cli[mcp]", "if-cli", "mcp", "foundry"]
275
+ }
276
+ }
277
+ }
278
+ ```
279
+
280
+ `--env`, `--writable`, `--catalog` and `--allow-tool` still work without a factory section
281
+ (`--env` is required in that case), and each one overrides the section when both are given:
282
+ a repeated `--env CODE=PROFILE` replaces that code's profile, `--writable`/`--catalog`, when
283
+ passed at all, replace the section's value outright, and `--allow-tool` is always additive.
284
+
285
+ | Flag | Meaning |
286
+ |---|---|
287
+ | `FACTORY` (optional, first positional) | Name of a `[factory <name>]` config section supplying the flags below. |
288
+ | `--env CODE=PROFILE` (repeatable, required without `FACTORY`) | The environment code the model passes, mapped to the `if-cli` profile that reaches it. |
289
+ | `--writable CODE` (repeatable) | Environments where write tools are allowed. Everything else is read-only. |
290
+ | `--read-only` | Forces every environment read-only, overriding both the section's `writable` and any `--writable`. Cannot be combined with `--writable`. |
291
+ | `--catalog CODE` (default: the first environment) | Whose tool list is published; its schemas are the reference every environment is checked against. |
292
+ | `--allow-tool NAME` (repeatable) | Extra tool names treated as read-only, beyond the shipped list. |
293
+ | `--timeout SECONDS` (default `120`) | Per upstream request; higher than the `api` command's default because some tools are slow. |
294
+
295
+ `mcp`'s `--timeout` does not read `INSIGHTFACTORY_REQUEST_TIMEOUT`: it parses the flag with
296
+ `parse_timeout_seconds` rather than `resolve_request_timeout`, because a long-running stdio
297
+ server is not the one-shot request that environment variable is scoped to.
298
+
299
+ Only environments passed to `--writable` accept mutating tools; every other
300
+ environment allows only tools it knows to read (an allowlist, not a
301
+ blocklist — a tool the model reaches through `invoke_tool` is never in the
302
+ published catalogue, so a blocklist could never cover everything callable).
303
+ Passing dev-only writes to production means changing an argument value, not
304
+ connecting to a different server, so keep `--writable` scoped to the
305
+ environments meant to be written by hand.
306
+
204
307
  ## Development
205
308
 
206
309
  ```bash
@@ -218,7 +321,9 @@ uv run if-cli --version
218
321
  INSIGHTFACTORY_CONFIG_DIR=$(mktemp -d) uv run if-cli profiles
219
322
  ```
220
323
 
221
- Python 3.10 or newer is required. Runtime code is stdlib-only.
324
+ Python 3.10 or newer is required. The base install has no runtime dependencies;
325
+ optional features, such as `if-cli mcp`, ship as extras and are imported lazily
326
+ inside their command module.
222
327
 
223
328
  ## Release
224
329
 
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: insightfactory-cli
3
- Version: 1.0.3.dev17
4
- Summary: Profile-based authentication CLI for the InsightFactory Interfaces API
5
- Project-URL: Homepage, https://insightfactory.ai
6
- Author-email: "insightfactory.ai Support" <support@insightfactory.ai>
7
- License-Expression: LicenseRef-Proprietary
8
- License-File: LICENSE
9
- Keywords: cli,insightfactory,oauth,pkce
10
- Classifier: Development Status :: 5 - Production/Stable
11
- Classifier: Environment :: Console
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: Other/Proprietary License
14
- Classifier: Programming Language :: Python :: 3 :: Only
15
- Classifier: Typing :: Typed
16
- Requires-Python: >=3.10
17
- Description-Content-Type: text/markdown
18
-
19
1
  # insightfactory-cli
20
2
 
21
3
  Profile-based authentication CLI for the InsightFactory Interfaces API. It
@@ -201,6 +183,15 @@ write to stdout, never launch a browser or block on interactive input, and only
201
183
  use bounded waits. Changing or removing any of them is a breaking change and
202
184
  needs a major version bump.
203
185
 
186
+ `get_valid_token` takes a keyword-only `force_refresh` (default `False`). When
187
+ `True`, it refreshes the cached token even if it is still fresh, which a
188
+ caller that just received a 401 from the factory needs: the cached token was
189
+ fresh a moment ago but the factory has since rejected it. With a refresh
190
+ token cached, this re-mints regardless of freshness; without one — a token
191
+ pasted through `if-cli set-token` — it raises `CliError` with the same
192
+ `if-cli login -p <name>` hint rather than serving the token that was just
193
+ rejected.
194
+
204
195
  ## How authentication works
205
196
 
206
197
  - Profiles live in `~/.insightfactory/config`, one per customer and environment.
@@ -219,6 +210,76 @@ needs a major version bump.
219
210
  the client ID from the factory's discovery document at runtime and holds no
220
211
  client secret.
221
212
 
213
+ ## MCP router (`if-cli mcp`)
214
+
215
+ Runs one MCP server over stdio that fronts several factory environments as a
216
+ single tool catalogue, instead of one MCP connection per environment. Every
217
+ tool gets a required `environment` argument and each call is forwarded to
218
+ that environment's own factory `/mcp` endpoint, authenticated with that
219
+ environment's `if-cli` profile — a profile's bearer token never reaches a
220
+ different environment's host. A tool is only offered on the environments
221
+ whose input schema matches the published one, so a factory on an older
222
+ release cannot be handed argument shapes it does not accept.
223
+
224
+ It needs the `mcp` extra, which is not part of the base install. Name its environments once
225
+ in a `[factory <name>]` section of `~/.insightfactory/config`, alongside profiles:
226
+
227
+ ```ini
228
+ [factory foundry]
229
+ dev = example-dev
230
+ tst = example-tst
231
+ writable = dev
232
+ ```
233
+
234
+ Every key except `writable` and `catalog` is `<environment code> = <profile name>`; file
235
+ order is the environment order. `writable` is a comma-separated list of codes (default
236
+ none); `catalog` is one code (default the first environment). Then:
237
+
238
+ ```bash
239
+ uv tool install 'insightfactory-cli[mcp]'
240
+ uvx --from 'insightfactory-cli[mcp]' if-cli mcp foundry
241
+ ```
242
+
243
+ Add it to `.mcp.json`:
244
+
245
+ ```json
246
+ {
247
+ "mcpServers": {
248
+ "if-factory": {
249
+ "command": "uvx",
250
+ "args": ["--from", "insightfactory-cli[mcp]", "if-cli", "mcp", "foundry"]
251
+ }
252
+ }
253
+ }
254
+ ```
255
+
256
+ `--env`, `--writable`, `--catalog` and `--allow-tool` still work without a factory section
257
+ (`--env` is required in that case), and each one overrides the section when both are given:
258
+ a repeated `--env CODE=PROFILE` replaces that code's profile, `--writable`/`--catalog`, when
259
+ passed at all, replace the section's value outright, and `--allow-tool` is always additive.
260
+
261
+ | Flag | Meaning |
262
+ |---|---|
263
+ | `FACTORY` (optional, first positional) | Name of a `[factory <name>]` config section supplying the flags below. |
264
+ | `--env CODE=PROFILE` (repeatable, required without `FACTORY`) | The environment code the model passes, mapped to the `if-cli` profile that reaches it. |
265
+ | `--writable CODE` (repeatable) | Environments where write tools are allowed. Everything else is read-only. |
266
+ | `--read-only` | Forces every environment read-only, overriding both the section's `writable` and any `--writable`. Cannot be combined with `--writable`. |
267
+ | `--catalog CODE` (default: the first environment) | Whose tool list is published; its schemas are the reference every environment is checked against. |
268
+ | `--allow-tool NAME` (repeatable) | Extra tool names treated as read-only, beyond the shipped list. |
269
+ | `--timeout SECONDS` (default `120`) | Per upstream request; higher than the `api` command's default because some tools are slow. |
270
+
271
+ `mcp`'s `--timeout` does not read `INSIGHTFACTORY_REQUEST_TIMEOUT`: it parses the flag with
272
+ `parse_timeout_seconds` rather than `resolve_request_timeout`, because a long-running stdio
273
+ server is not the one-shot request that environment variable is scoped to.
274
+
275
+ Only environments passed to `--writable` accept mutating tools; every other
276
+ environment allows only tools it knows to read (an allowlist, not a
277
+ blocklist — a tool the model reaches through `invoke_tool` is never in the
278
+ published catalogue, so a blocklist could never cover everything callable).
279
+ Passing dev-only writes to production means changing an argument value, not
280
+ connecting to a different server, so keep `--writable` scoped to the
281
+ environments meant to be written by hand.
282
+
222
283
  ## Development
223
284
 
224
285
  ```bash
@@ -236,7 +297,9 @@ uv run if-cli --version
236
297
  INSIGHTFACTORY_CONFIG_DIR=$(mktemp -d) uv run if-cli profiles
237
298
  ```
238
299
 
239
- Python 3.10 or newer is required. Runtime code is stdlib-only.
300
+ Python 3.10 or newer is required. The base install has no runtime dependencies;
301
+ optional features, such as `if-cli mcp`, ship as extras and are imported lazily
302
+ inside their command module.
240
303
 
241
304
  ## Release
242
305
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "insightfactory-cli"
3
- version = "1.0.3.dev17"
3
+ version = "1.0.3.dev19"
4
4
  description = "Profile-based authentication CLI for the InsightFactory Interfaces API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -23,12 +23,15 @@ if-cli = "if_cli.main:main"
23
23
  [project.urls]
24
24
  Homepage = "https://insightfactory.ai"
25
25
 
26
+ [project.optional-dependencies]
27
+ mcp = ["mcp>=2.1,<3", "anyio", "httpx2", "mcp-types", "exceptiongroup; python_version < '3.11'"]
28
+
26
29
  [build-system]
27
30
  requires = ["hatchling"]
28
31
  build-backend = "hatchling.build"
29
32
 
30
33
  [dependency-groups]
31
- dev = ["pytest", "ruff", "ty"]
34
+ dev = ["pytest", "ruff", "ty", "insightfactory-cli[mcp]"]
32
35
 
33
36
  [tool.hatch.build.targets.wheel]
34
37
  packages = ["src/if_cli"]
@@ -53,6 +53,8 @@ def _parse_args_with_positionals(argv: list[str], *, options: Options) -> tuple[
53
53
  """Parse argv allowing options and positionals in any order (Node util.parseArgs parity)."""
54
54
  values: dict[str, Any] = {}
55
55
  for name, spec in options.items():
56
+ if spec.get("repeat"):
57
+ die(f"internal error: option '--{name}' has repeat=True, which allow_positionals parsing does not support")
56
58
  if spec["type"] == "boolean":
57
59
  values[name] = spec.get("default", False)
58
60
  else:
@@ -136,6 +138,10 @@ def parse_args(
136
138
  dest = name.replace("-", "_")
137
139
  if spec["type"] == "boolean":
138
140
  parser.add_argument(*flags, dest=dest, action="store_true", default=spec.get("default", False))
141
+ elif spec.get("repeat"):
142
+ # `action="append"` with `default=None` (rather than a spec-level `[]`) so
143
+ # unset stays None and a shared mutable default can't leak across calls.
144
+ parser.add_argument(*flags, dest=dest, action="append", default=None)
139
145
  else:
140
146
  parser.add_argument(*flags, dest=dest, default=spec.get("default"))
141
147
  namespace = parser.parse_args(argv)
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import sys
5
+
6
+ from if_cli.cli import Options, parse_args
7
+ from if_cli.config import config_file, get_profile, list_factories, load_config, parse_factory_section
8
+ from if_cli.http import parse_timeout_seconds
9
+ from if_cli.runtime import die
10
+
11
+ MCP_OPTIONS: Options = {
12
+ "env": {"type": "string", "repeat": True},
13
+ "writable": {"type": "string", "repeat": True},
14
+ "read-only": {"type": "boolean"},
15
+ "catalog": {"type": "string"},
16
+ "allow-tool": {"type": "string", "repeat": True},
17
+ "timeout": {"type": "string", "default": "120"},
18
+ }
19
+
20
+ MCP_USAGE = (
21
+ "usage: if-cli mcp [FACTORY] [--env CODE=PROFILE ...]\n"
22
+ " [--writable CODE ... | --read-only] [--catalog CODE]\n"
23
+ " [--allow-tool NAME ...] [--timeout SECONDS]\n"
24
+ "\n"
25
+ "Runs one MCP server over stdio that fronts several factory environments: every\n"
26
+ "tool gets a required 'environment' argument, routed to that environment's own\n"
27
+ "if-cli profile. FACTORY names a [factory NAME] section in the config file,\n"
28
+ "supplying its environments, --writable and --catalog; flags override the\n"
29
+ "section. Without FACTORY, --env is required at least once. --catalog defaults\n"
30
+ "to the first environment; environments not passed to --writable are read-only;\n"
31
+ "--read-only forces every environment read-only and cannot be combined with\n"
32
+ "--writable.\n"
33
+ )
34
+
35
+ MISSING_MCP_EXTRA = (
36
+ "if-cli mcp needs the mcp extra — install with: uv tool install 'insightfactory-cli[mcp]'\n"
37
+ "(or: uvx --from 'insightfactory-cli[mcp]' if-cli mcp ...)"
38
+ )
39
+
40
+
41
+ def _parse_env_mapping(raw: str) -> tuple[str, str]:
42
+ code, separator, profile_name = raw.partition("=")
43
+ if not separator or not code or not profile_name:
44
+ die(f"--env must be CODE=PROFILE (received '{raw}')\n\n{MCP_USAGE}")
45
+ return code, profile_name
46
+
47
+
48
+ def _stray_positional(rest: list[str]) -> str | None:
49
+ """Find a bare (non-flag) token in `rest`, skipping tokens consumed as a
50
+ preceding flag's value. `rest` is `argv` with a leading FACTORY already
51
+ peeled off, so any bare token found here is a second, misplaced one."""
52
+ index = 0
53
+ while index < len(rest):
54
+ token = rest[index]
55
+ if token == "--":
56
+ return rest[index + 1] if index + 1 < len(rest) else None
57
+ if token.startswith("--"):
58
+ name, has_inline_value, _value = token[2:].partition("=")
59
+ spec = MCP_OPTIONS.get(name)
60
+ if spec is not None and spec["type"] != "boolean" and not has_inline_value:
61
+ index += 2
62
+ continue
63
+ index += 1
64
+ continue
65
+ if token.startswith("-") and len(token) > 1:
66
+ index += 1
67
+ continue
68
+ return token
69
+ return None
70
+
71
+
72
+ def mcp_command(argv: list[str]) -> None:
73
+ if "-h" in argv or "--help" in argv:
74
+ sys.stdout.write(MCP_USAGE)
75
+ return
76
+
77
+ factory_name: str | None = None
78
+ rest = argv
79
+ if argv and not argv[0].startswith("-"):
80
+ factory_name, *rest = argv
81
+
82
+ stray = _stray_positional(rest)
83
+ if stray is not None:
84
+ die(f"FACTORY must come before the flags: if-cli mcp {stray} --env ...\n\n{MCP_USAGE}")
85
+
86
+ values, _positionals = parse_args(rest, options=MCP_OPTIONS)
87
+ raw_envs: list[str] = values["env"] or []
88
+ raw_writable: list[str] | None = values["writable"]
89
+ read_only: bool = values["read-only"]
90
+
91
+ if read_only and raw_writable is not None:
92
+ die(f"--read-only cannot be combined with --writable\n\n{MCP_USAGE}")
93
+
94
+ config = load_config()
95
+
96
+ # A dict, not a list of pairs: --env "adds or replaces that code's profile"
97
+ # (a factory's own environments first, then each --env in turn), so a
98
+ # repeated code is the way to override one, not a mistake to reject.
99
+ # Overriding an existing code keeps its original position (a dict update,
100
+ # not a delete-and-reinsert), which is what keeps the --catalog default
101
+ # (codes[0]) stable under a partial override.
102
+ mappings: dict[str, str] = {}
103
+ writable_codes: frozenset[str] = frozenset()
104
+ catalog_source: str | None = None
105
+
106
+ if factory_name is not None:
107
+ factory = parse_factory_section(config, factory_name)
108
+ mappings.update(factory["environments"])
109
+ writable_codes = factory["writable"]
110
+ catalog_source = factory["catalog"]
111
+
112
+ for raw in raw_envs:
113
+ code, profile_name = _parse_env_mapping(raw)
114
+ mappings[code] = profile_name
115
+
116
+ if not mappings:
117
+ factories = sorted(list_factories(config))
118
+ hint = f" or name one of the factories in {config_file()}: {', '.join(factories)}" if factories else ""
119
+ die(f"--env is required (at least one){hint}\n\n{MCP_USAGE}")
120
+ codes = list(mappings)
121
+
122
+ if read_only:
123
+ writable_codes = frozenset()
124
+ elif raw_writable is not None:
125
+ writable_codes = frozenset(raw_writable)
126
+ if values["catalog"] is not None:
127
+ catalog_source = values["catalog"]
128
+ if catalog_source is None:
129
+ catalog_source = codes[0]
130
+
131
+ # Checked with find_spec rather than a bare try/except ImportError so a genuine
132
+ # import bug inside if_cli.router propagates instead of reading as a missing extra.
133
+ if importlib.util.find_spec("mcp") is None:
134
+ die(MISSING_MCP_EXTRA)
135
+ from if_cli.router.server import build_router, log, run_server
136
+
137
+ timeout = parse_timeout_seconds(values["timeout"], "--timeout")
138
+ extra_read_only = frozenset(values["allow-tool"] or [])
139
+
140
+ if catalog_source not in codes:
141
+ die(f"--catalog '{catalog_source}' is not one of the --env codes: {', '.join(codes)}")
142
+
143
+ unknown_writable = sorted(code for code in writable_codes if code not in codes)
144
+ if unknown_writable:
145
+ named = ", ".join(f"'{code}'" for code in unknown_writable)
146
+ die(f"--writable {named} not among the --env codes: {', '.join(codes)}")
147
+
148
+ profiles = {code: get_profile(config, profile_name) for code, profile_name in mappings.items()}
149
+
150
+ label = factory_name if factory_name is not None else "environments"
151
+ parts = [
152
+ f"{code}={mappings[code]} {profiles[code]['host']}{' (writable)' if code in writable_codes else ''}"
153
+ for code in codes
154
+ ]
155
+ log(f"{label}: {'; '.join(parts)}; catalog={catalog_source}")
156
+
157
+ router = build_router(
158
+ profiles=profiles,
159
+ writable_codes=writable_codes,
160
+ catalog_source=catalog_source,
161
+ extra_read_only=extra_read_only,
162
+ timeout=timeout,
163
+ )
164
+ run_server(router)
@@ -8,7 +8,7 @@ from typing import Literal, TypedDict
8
8
  from if_cli.cache import TokenCache, is_fresh, load_cache
9
9
  from if_cli.cli import Options, parse_args
10
10
  from if_cli.colour import Colour, colourise
11
- from if_cli.config import config_file, get_profile, load_config
11
+ from if_cli.config import config_file, get_profile, is_factory_section, load_config
12
12
  from if_cli.runtime import format_expiry
13
13
 
14
14
  TokenStatus = Literal["valid", "expired", "none", "unknown"]
@@ -41,6 +41,8 @@ def summarize_profiles(
41
41
  ) -> list[ProfileSummary]:
42
42
  summaries: list[ProfileSummary] = []
43
43
  for section in config:
44
+ if is_factory_section(section):
45
+ continue
44
46
  name = "DEFAULT" if section == "" else section
45
47
  try:
46
48
  profile = get_profile(config, name)
@@ -23,6 +23,17 @@ class Profile(TypedDict):
23
23
  organization: str | None
24
24
 
25
25
 
26
+ class Factory(TypedDict):
27
+ name: str
28
+ environments: list[tuple[str, str]]
29
+ writable: frozenset[str]
30
+ catalog: str
31
+
32
+
33
+ FACTORY_SECTION_PREFIX = "factory "
34
+ FACTORY_RESERVED_KEYS = {"writable", "catalog"}
35
+
36
+
26
37
  def config_dir() -> str:
27
38
  if "INSIGHTFACTORY_CONFIG_DIR" in os.environ:
28
39
  return os.environ["INSIGHTFACTORY_CONFIG_DIR"]
@@ -163,7 +174,82 @@ def resolve_profile_name(flag: str | None = None) -> str:
163
174
  return name
164
175
 
165
176
 
177
+ def is_factory_section(name: str) -> bool:
178
+ return name.startswith(FACTORY_SECTION_PREFIX)
179
+
180
+
181
+ def list_factories(config: dict[str, dict[str, str]]) -> list[str]:
182
+ return [section.removeprefix(FACTORY_SECTION_PREFIX) for section in config if is_factory_section(section)]
183
+
184
+
185
+ def parse_factory_section(config: dict[str, dict[str, str]], name: str) -> Factory:
186
+ """Parse a `[factory <name>]` section on its own: no check that a `writable`/
187
+ `catalog` code is among its environments, and none of its profiles are resolved.
188
+
189
+ A caller that means to merge `--env`/`--writable`/`--catalog` overrides on top
190
+ (`commands/mcp.py`) validates the merged result once, afterwards, rather than
191
+ validating this unmerged section only to re-validate the effective mapping a
192
+ second time. `get_factory` below runs both steps for a caller that has no
193
+ overrides to merge.
194
+ """
195
+ section_key = f"{FACTORY_SECTION_PREFIX}{name}"
196
+ if section_key not in config:
197
+ known = sorted(list_factories(config))
198
+ hint = f"known factories: {', '.join(known)}" if known else "no factory sections defined"
199
+ die(
200
+ f"factory '{name}' not found in {config_file()} — add a [factory {name}] section with "
201
+ f"<code> = <profile> lines ({hint})"
202
+ )
203
+ section = config[section_key]
204
+
205
+ environments = [(code, profile_name) for code, profile_name in section.items() if code not in FACTORY_RESERVED_KEYS]
206
+ if not environments:
207
+ die(f"factory '{name}' in {config_file()} has no environments — add <code> = <profile> lines")
208
+ for code, profile_name in environments:
209
+ if not profile_name.strip():
210
+ die(
211
+ f"factory '{name}' in {config_file()}: environment '{code}' has an empty profile "
212
+ f"(found '{code} =' with no value)"
213
+ )
214
+ codes = [code for code, _profile_name in environments]
215
+
216
+ writable = frozenset(code.strip() for code in section.get("writable", "").split(",") if code.strip())
217
+ catalog_value = section.get("catalog")
218
+ catalog = catalog_value.strip() if catalog_value is not None and catalog_value.strip() else codes[0]
219
+
220
+ return {"name": name, "environments": environments, "writable": writable, "catalog": catalog}
221
+
222
+
223
+ def get_factory(config: dict[str, dict[str, str]], name: str) -> Factory:
224
+ """Parse and fully validate a `[factory <name>]` section: every profile it
225
+ references exists, and its own `writable`/`catalog` codes are among its own
226
+ environments. A caller that merges overrides on top of the section should use
227
+ `parse_factory_section` instead, and validate the merged result once."""
228
+ factory = parse_factory_section(config, name)
229
+ codes = [code for code, _profile_name in factory["environments"]]
230
+
231
+ unknown_writable = sorted(code for code in factory["writable"] if code not in codes)
232
+ if unknown_writable:
233
+ named = ", ".join(f"'{code}'" for code in unknown_writable)
234
+ die(f"factory '{name}' in {config_file()}: writable {named} not among its environments: {', '.join(codes)}")
235
+
236
+ if factory["catalog"] not in codes:
237
+ die(
238
+ f"factory '{name}' in {config_file()}: catalog '{factory['catalog']}' is not among its environments: "
239
+ f"{', '.join(codes)}"
240
+ )
241
+
242
+ for _code, profile_name in factory["environments"]:
243
+ get_profile(config, profile_name)
244
+
245
+ return factory
246
+
247
+
166
248
  def get_profile(config: dict[str, dict[str, str]], name: str) -> Profile:
249
+ if is_factory_section(name):
250
+ die(
251
+ f"'{name}' is a factory section, not a profile; use: if-cli mcp {name.removeprefix(FACTORY_SECTION_PREFIX)}"
252
+ )
167
253
  section_key = "" if name == "DEFAULT" else name
168
254
  if section_key not in config:
169
255
  die(f"profile '{name}' not found in {config_file()} — run: if-cli login -p {name} --host <factory-url>")
@@ -7,6 +7,7 @@ from if_cli.commands.api import api_command
7
7
  from if_cli.commands.config import config_command
8
8
  from if_cli.commands.login import login_command
9
9
  from if_cli.commands.logout import logout_command
10
+ from if_cli.commands.mcp import mcp_command
10
11
  from if_cli.commands.profiles import profiles_command
11
12
  from if_cli.commands.set_token import set_token_command
12
13
  from if_cli.commands.token import token_command
@@ -30,6 +31,12 @@ commands:
30
31
  [-p profile] [-X METHOD] [-d DATA] [--timeout SECONDS] <path>
31
32
  routes [-p profile] [--timeout SECONDS] [filter]
32
33
  describe [-p profile] [--timeout SECONDS] METHOD <path>
34
+ mcp run one MCP server that routes across factory environments
35
+ [FACTORY] [--env CODE=PROFILE ...]
36
+ [--writable CODE ... | --read-only] [--catalog CODE]
37
+ [--allow-tool NAME ...] [--timeout SECONDS]
38
+ FACTORY names a [factory NAME] config section; flags override it
39
+ (needs the mcp extra: uv tool install 'insightfactory-cli[mcp]')
33
40
 
34
41
  profile selection: -p flag, then ${ENV_PROFILE}, then [DEFAULT].
35
42
  request timeout: --timeout flag, then ${ENV_REQUEST_TIMEOUT}, then 30 seconds.
@@ -60,6 +67,9 @@ def run_cli(argv: list[str]) -> None:
60
67
  if command == "api":
61
68
  api_command(rest)
62
69
  return
70
+ if command == "mcp":
71
+ mcp_command(rest)
72
+ return
63
73
  if command in {None, "-h", "--help"}:
64
74
  import sys
65
75