insightfactory-cli 1.0.2.dev16__py3-none-any.whl → 1.0.3.dev19__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/cli.py CHANGED
@@ -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)
if_cli/commands/mcp.py ADDED
@@ -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)
if_cli/config.py CHANGED
@@ -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>")
if_cli/main.py CHANGED
@@ -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
 
if_cli/oauth.py CHANGED
@@ -346,23 +346,28 @@ def _refresh(host: str, entry: CacheEntry, refresh_token: str, client_id: str) -
346
346
  return store_tokens(host, tokens, entry["token_endpoint"], client_id)
347
347
 
348
348
 
349
- def _wait_for_fresh_token(host: str) -> str | None:
349
+ def _wait_for_fresh_token(host: str, *, exclude: str | None = None) -> str | None:
350
350
  for attempt in range(RECOVERY_POLL_ATTEMPTS):
351
351
  entry = load_cache()["tokens"].get(host)
352
- if entry and is_fresh(entry):
352
+ if entry and is_fresh(entry) and entry["access_token"] != exclude:
353
353
  return entry["access_token"]
354
354
  if attempt + 1 < RECOVERY_POLL_ATTEMPTS:
355
355
  time.sleep(RECOVERY_POLL_INTERVAL_MS / 1000)
356
356
  return None
357
357
 
358
358
 
359
- def get_valid_token(profile: Profile) -> str:
359
+ def get_valid_token(profile: Profile, *, force_refresh: bool = False) -> str:
360
360
  entry = load_cache()["tokens"].get(profile["host"])
361
361
  if not entry:
362
362
  die(f"no cached token for {profile['host']} — run: if-cli login -p {profile['name']}")
363
- if is_fresh(entry):
363
+ if not force_refresh and is_fresh(entry):
364
364
  return entry["access_token"]
365
365
  refresh_token = entry.get("refresh_token")
366
+ if force_refresh and not refresh_token:
367
+ die(
368
+ f"cannot refresh token for {profile['host']}: no refresh token cached (a pasted static token) — "
369
+ f"run: if-cli login -p {profile['name']}"
370
+ )
366
371
  if refresh_token:
367
372
  client_id = entry.get("client_id")
368
373
  if not client_id:
@@ -378,7 +383,9 @@ def get_valid_token(profile: Profile) -> str:
378
383
  if isinstance(error, TokenRequestError) and error.invalidates_refresh_token:
379
384
  cleared = clear_refresh_token_if_matches(profile["host"], refresh_token)
380
385
  if cleared:
381
- recovered_access_token = _wait_for_fresh_token(profile["host"])
386
+ recovered_access_token = _wait_for_fresh_token(
387
+ profile["host"], exclude=entry["access_token"] if force_refresh else None
388
+ )
382
389
  if recovered_access_token:
383
390
  return recovered_access_token
384
391
  else:
File without changes
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from if_cli.runtime import CliError
10
+
11
+ ENV_ARG = "environment"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class EnvironmentSpec:
16
+ code: str
17
+ label: str
18
+ writable: bool
19
+
20
+
21
+ def fingerprint(schema: dict[str, Any] | None) -> str:
22
+ """First 12 hex chars of the sha1 of the schema's canonical JSON."""
23
+ encoded = json.dumps(schema or {}, sort_keys=True, separators=(",", ":"))
24
+ return hashlib.sha1(encoded.encode("utf-8"), usedforsecurity=False).hexdigest()[:12] # nosec B324
25
+
26
+
27
+ def _offered_environments(
28
+ tool_name: str,
29
+ reference: str,
30
+ catalogues: Mapping[str, list[dict[str, Any]] | None],
31
+ environments: Sequence[EnvironmentSpec],
32
+ ) -> list[str]:
33
+ offered: list[str] = []
34
+ for env in environments:
35
+ remote = catalogues.get(env.code)
36
+ if remote is None:
37
+ # Never listed, so nothing to compare. Offer it and let the call fail
38
+ # with the login hint rather than hiding the environment entirely.
39
+ offered.append(env.code)
40
+ continue
41
+ match = next((candidate for candidate in remote if candidate.get("name") == tool_name), None)
42
+ if match is None or fingerprint(match.get("inputSchema")) != reference:
43
+ continue
44
+ offered.append(env.code)
45
+ return offered
46
+
47
+
48
+ def splice_environment(
49
+ tool: dict[str, Any], offered: list[str], environments: Sequence[EnvironmentSpec]
50
+ ) -> dict[str, Any]:
51
+ offered_set = set(offered)
52
+ description = "Which factory environment to run this against. " + (
53
+ "; ".join(
54
+ f"{env.code} = {env.label}{'' if env.writable else ' (read-only)'}"
55
+ for env in environments
56
+ if env.code in offered_set
57
+ )
58
+ + "."
59
+ )
60
+ dropped = [env.code for env in environments if env.code not in offered_set]
61
+ if dropped:
62
+ description += (
63
+ f" Not available on {', '.join(dropped)}, which run a different release: "
64
+ "the tool is missing there, or its arguments differ."
65
+ )
66
+
67
+ input_schema = dict(tool.get("inputSchema") or {})
68
+ properties = dict(input_schema.get("properties") or {})
69
+ properties[ENV_ARG] = {"type": "string", "enum": list(offered), "description": description}
70
+ input_schema["properties"] = properties
71
+ required = list(input_schema.get("required") or [])
72
+ if ENV_ARG not in required:
73
+ required.append(ENV_ARG)
74
+ input_schema["required"] = required
75
+
76
+ return {**tool, "inputSchema": input_schema}
77
+
78
+
79
+ def merge_catalogs(
80
+ source: list[dict[str, Any]],
81
+ catalogues: Mapping[str, list[dict[str, Any]] | None],
82
+ environments: Sequence[EnvironmentSpec],
83
+ ) -> list[dict[str, Any]]:
84
+ """Publish `source`'s tools, splicing an `environment` enum into each one.
85
+
86
+ A tool offers an environment only when that environment's own copy of the
87
+ tool has an identical input-schema fingerprint, so a version-skewed
88
+ factory can never be handed argument shapes it does not accept.
89
+ """
90
+ reserved = [tool["name"] for tool in source if ENV_ARG in ((tool.get("inputSchema") or {}).get("properties") or {})]
91
+ if reserved:
92
+ raise CliError(
93
+ f'tool(s) {", ".join(reserved)} already declare an "{ENV_ARG}" argument, which the router reserves '
94
+ "to select the factory environment; rename it upstream or exclude the tool from --catalog."
95
+ )
96
+
97
+ merged = []
98
+ for tool in source:
99
+ reference = fingerprint(tool.get("inputSchema"))
100
+ offered = _offered_environments(tool["name"], reference, catalogues, environments)
101
+ merged.append(splice_environment(tool, offered, environments))
102
+ return merged
103
+
104
+
105
+ def revalidate(
106
+ code: str,
107
+ remote: list[dict[str, Any]] | None,
108
+ *,
109
+ source: list[dict[str, Any]],
110
+ env_by_tool: Mapping[str, list[str]],
111
+ ) -> dict[str, list[str]]:
112
+ """Recompute `env_by_tool` after a previously-unreachable `code` answers.
113
+
114
+ `code` was offered on every tool by default (see `_offered_environments`)
115
+ since there was nothing to compare it against at startup. Now that its
116
+ real catalogue is in hand, drop it from any tool it turns out not to
117
+ offer, or offers with a different schema, so a later call is refused
118
+ without another round trip. Every other environment's entries are
119
+ returned unchanged.
120
+ """
121
+ remote_by_name = {tool.get("name"): tool for tool in (remote or [])}
122
+ updated = dict(env_by_tool)
123
+ for tool in source:
124
+ name = tool["name"]
125
+ offered = env_by_tool.get(name)
126
+ if offered is None or code not in offered:
127
+ continue
128
+ reference = fingerprint(tool.get("inputSchema"))
129
+ match = remote_by_name.get(name)
130
+ if match is None or fingerprint(match.get("inputSchema")) != reference:
131
+ updated[name] = [candidate for candidate in offered if candidate != code]
132
+ return updated
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ READ_ONLY_TOOLS = frozenset(
7
+ {
8
+ "tool_search",
9
+ "invoke_tool",
10
+ "find_production_lines",
11
+ "get_production_line",
12
+ "find_tasks",
13
+ "get_task",
14
+ "find_activities",
15
+ "get_task_config_schema",
16
+ "get_notebook",
17
+ "debug_production_line",
18
+ "debug_task",
19
+ "get_failed_tasks",
20
+ "run_sql_query",
21
+ }
22
+ )
23
+
24
+ READ_ONLY_PREFIXES = ("get_", "find_", "list_", "debug_")
25
+
26
+ _MAX_INVOKE_TOOL_DEPTH = 8
27
+
28
+
29
+ def _resolve_invoke_target(args: Mapping[str, Any], *, depth: int = 0) -> str | None:
30
+ """Descend through nested `invoke_tool` calls to find the real target.
31
+
32
+ `invoke_tool` is itself allowlisted, so
33
+ `invoke_tool(tool_name="invoke_tool", tool_input={"tool_name": "create_task", ...})`
34
+ hides its real, mutating target one level down in `tool_input`. Keep
35
+ unwrapping while the resolved name is still `invoke_tool`, capped so a
36
+ pathological chain cannot recurse unbounded.
37
+ """
38
+ if depth > _MAX_INVOKE_TOOL_DEPTH:
39
+ return None
40
+ tool_name = args.get("tool_name")
41
+ if not isinstance(tool_name, str) or not tool_name:
42
+ return None
43
+ if tool_name != "invoke_tool":
44
+ return tool_name
45
+ tool_input = args.get("tool_input")
46
+ if not isinstance(tool_input, Mapping):
47
+ return None
48
+ return _resolve_invoke_target(tool_input, depth=depth + 1)
49
+
50
+
51
+ def blocked_write(
52
+ name: str,
53
+ args: Mapping[str, Any],
54
+ *,
55
+ code: str,
56
+ writable: bool,
57
+ extra_read_only: frozenset[str] = frozenset(),
58
+ ) -> str | None:
59
+ """The write gate, on a read-only environment: allow only tools known to read.
60
+
61
+ Deny by default rather than blocking a list of known writers. A model can
62
+ reach a tool outside the published catalogue through `invoke_tool`, so a
63
+ blocklist drawn from that catalogue could never cover what is actually
64
+ callable, and a mutating tool added upstream later would arrive permitted.
65
+ """
66
+ if writable:
67
+ return None
68
+
69
+ via = " (through invoke_tool)" if name == "invoke_tool" else ""
70
+ target = _resolve_invoke_target(args) if name == "invoke_tool" else name
71
+
72
+ if target is None:
73
+ return (
74
+ f'[{code}] could not determine what tool "{name}"{via} targets, so it is denied on {code}, '
75
+ f"which is configured read-only. Pass --allow-tool <name> or --writable {code}."
76
+ )
77
+
78
+ if target in READ_ONLY_TOOLS or target in extra_read_only:
79
+ return None
80
+ if any(target.startswith(prefix) for prefix in READ_ONLY_PREFIXES):
81
+ return None
82
+
83
+ return (
84
+ f'[{code}] "{target}"{via} is not a known read-only tool and {code} is configured read-only. '
85
+ f"Pass --allow-tool {target} or --writable {code}."
86
+ )
@@ -0,0 +1,285 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from importlib.metadata import version
7
+ from typing import Any
8
+
9
+ import anyio
10
+ import mcp_types as types
11
+ from mcp.server import Server
12
+ from mcp.server.context import ServerRequestContext
13
+ from mcp.server.stdio import stdio_server
14
+
15
+ from if_cli.config import Profile
16
+ from if_cli.router import catalog, policy
17
+ from if_cli.router.catalog import ENV_ARG, EnvironmentSpec
18
+ from if_cli.router.upstream import Upstream
19
+ from if_cli.runtime import CliError
20
+
21
+ SERVER_NAME = "if-cli-mcp-router"
22
+ SERVER_VERSION = version("insightfactory-cli")
23
+
24
+ DEFAULT_CATALOG_RETRY_WINDOW_S = 5.0
25
+
26
+
27
+ def log(message: str) -> None:
28
+ sys.stderr.write(f"[if-cli mcp] {message}\n")
29
+
30
+
31
+ def _catalog_retry_window_seconds() -> float:
32
+ """Read the catalogue-rebuild retry window, in milliseconds, from the environment.
33
+
34
+ Not a documented `if-cli mcp` flag: this exists so tests can shrink the
35
+ window instead of sleeping out the real 5 seconds, mirroring
36
+ `if_cli.cache`'s `INSIGHTFACTORY_CACHE_LOCK_*` knobs.
37
+ """
38
+ raw = os.environ.get("INSIGHTFACTORY_MCP_CATALOG_RETRY_WINDOW_MS")
39
+ if raw is None:
40
+ return DEFAULT_CATALOG_RETRY_WINDOW_S
41
+ try:
42
+ value = float(raw)
43
+ except ValueError:
44
+ return DEFAULT_CATALOG_RETRY_WINDOW_S
45
+ return value / 1000.0 if value > 0 else DEFAULT_CATALOG_RETRY_WINDOW_S
46
+
47
+
48
+ def _error_result(text: str) -> types.CallToolResult:
49
+ return types.CallToolResult(content=[types.TextContent(text=text)], is_error=True)
50
+
51
+
52
+ @dataclass
53
+ class RouterConfig:
54
+ environments: list[EnvironmentSpec]
55
+ catalog_source: str
56
+ extra_read_only: frozenset[str]
57
+
58
+
59
+ class Router:
60
+ """Fronts several factory MCP endpoints as one server.
61
+
62
+ The catalogue comes from one environment (`config.catalog_source`) with an
63
+ `environment` enum spliced into every tool's input schema (see
64
+ `router.catalog`). A tool only offers an environment whose copy of that
65
+ tool has an identical input-schema fingerprint, so a version-skewed
66
+ factory can never be handed argument shapes it does not accept.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ config: RouterConfig,
72
+ upstreams: dict[str, Upstream],
73
+ *,
74
+ catalog_retry_window: float = DEFAULT_CATALOG_RETRY_WINDOW_S,
75
+ ) -> None:
76
+ self.config = config
77
+ self.upstreams = upstreams
78
+ self.tools: list[types.Tool] | None = None
79
+ self.env_by_tool: dict[str, list[str]] = {}
80
+ self.catalogues: dict[str, list[dict[str, Any]] | None] = {}
81
+ self.unverified: set[str] = set()
82
+ self._catalog_lock = anyio.Lock()
83
+ self._catalog_retry_window = catalog_retry_window
84
+ self._catalog_error: Exception | None = None
85
+ self._catalog_error_at: float = float("-inf")
86
+
87
+ def _label(self, code: str) -> str:
88
+ return next(env.label for env in self.config.environments if env.code == code)
89
+
90
+ async def _catalogues(self) -> dict[str, list[dict[str, Any]] | None]:
91
+ results: dict[str, list[dict[str, Any]] | None] = {}
92
+
93
+ async def fetch(code: str, upstream: Upstream) -> None:
94
+ try:
95
+ results[code] = await upstream.list_tools()
96
+ except Exception as error:
97
+ log(f"WARN {code} unreachable at startup: {error}")
98
+ results[code] = None
99
+
100
+ # Three environments can each hit get_valid_token's refresh path here
101
+ # concurrently; with_cache_lock (if_cli.cache) serialises the cache
102
+ # read-modify-write, so that race is safe by construction, not by luck.
103
+ async with anyio.create_task_group() as task_group:
104
+ for code, upstream in self.upstreams.items():
105
+ task_group.start_soon(fetch, code, upstream)
106
+ return results
107
+
108
+ async def build_catalog(self) -> list[types.Tool]:
109
+ """Build the merged catalogue. Safe to call again to pick up a new login."""
110
+ catalogues = await self._catalogues()
111
+ source = catalogues.get(self.config.catalog_source)
112
+ if source is None:
113
+ raise CliError(
114
+ f'catalog source "{self.config.catalog_source}" is unreachable, so there is no catalogue to '
115
+ f"publish. Log in with: if-cli login -p {self._label(self.config.catalog_source)}"
116
+ )
117
+
118
+ self.catalogues = catalogues
119
+ self.unverified = {code for code, remote in catalogues.items() if remote is None}
120
+ merged = catalog.merge_catalogs(source, catalogues, self.config.environments)
121
+ self.env_by_tool = {tool["name"]: tool["inputSchema"]["properties"][ENV_ARG]["enum"] for tool in merged}
122
+ tools = [types.Tool.model_validate(tool) for tool in merged]
123
+ self.tools = tools
124
+ log(f"catalogue: {len(tools)} tools from {self.config.catalog_source}")
125
+ return tools
126
+
127
+ async def prime_catalog(self) -> None:
128
+ """Build the catalogue in the background so `initialize` isn't held hostage.
129
+
130
+ Run as a task alongside `stdio_server`/`server.run` rather than before
131
+ them, so a hung factory reads as a slow tool list, not a failed
132
+ handshake. A failure here is already logged by `_ensure_catalog`; the
133
+ first real `list_tools`/`call_tool` request retries it (subject to the
134
+ retry window) instead of crashing the process mid-handshake.
135
+ """
136
+ try:
137
+ await self._ensure_catalog()
138
+ except Exception:
139
+ pass
140
+
141
+ async def _ensure_catalog(self) -> None:
142
+ """Build the catalogue on first use, and keep retrying on later failures.
143
+
144
+ A failed build (e.g. the catalog source has no cached token yet) is
145
+ not permanent: the next caller retries it, but no more often than
146
+ once per `_catalog_retry_window`, so a client polling `tools/list`
147
+ cannot hammer three factories while a login is still pending.
148
+ """
149
+ async with self._catalog_lock:
150
+ if self.tools is not None:
151
+ return
152
+ if self._catalog_error is not None:
153
+ if anyio.current_time() - self._catalog_error_at < self._catalog_retry_window:
154
+ raise self._catalog_error
155
+ try:
156
+ await self.build_catalog()
157
+ except Exception as error:
158
+ log(f"catalogue build failed: {error}")
159
+ self._catalog_error = error
160
+ self._catalog_error_at = anyio.current_time()
161
+ raise
162
+ self._catalog_error = None
163
+
164
+ async def _revalidate(self, code: str, name: str) -> types.CallToolResult | None:
165
+ """Re-check a previously-unreachable environment before routing to it.
166
+
167
+ Returns an error result if the call should stop here (upstream still
168
+ unreachable, or it turns out not to offer `name`), or `None` to proceed.
169
+ """
170
+ try:
171
+ remote = await self.upstreams[code].list_tools()
172
+ except Exception as error:
173
+ message = str(error)
174
+ return _error_result(message if message.startswith(f"[{code}]") else f"[{code}] {message}")
175
+
176
+ self.catalogues[code] = remote
177
+ self.unverified.discard(code)
178
+ source = self.catalogues.get(self.config.catalog_source) or []
179
+ self.env_by_tool = catalog.revalidate(code, remote, source=source, env_by_tool=self.env_by_tool)
180
+ # Only tools the router actually publishes are subject to the offer
181
+ # check; a name reached directly (e.g. through invoke_tool) that was
182
+ # never part of the merged catalogue is forwarded unconditionally,
183
+ # exactly as it already is on an environment that was verified from
184
+ # the start (see the plain `self.env_by_tool.get(name)` check below).
185
+ if name in self.env_by_tool and code not in self.env_by_tool[name]:
186
+ return _error_result(f'[{code}] does not offer "{name}" (missing there, or its arguments differ).')
187
+ return None
188
+
189
+ async def list_tools(self) -> list[types.Tool]:
190
+ await self._ensure_catalog()
191
+ assert self.tools is not None
192
+ return self.tools
193
+
194
+ async def call_tool(self, name: str, raw_arguments: dict[str, Any] | None) -> types.CallToolResult:
195
+ try:
196
+ await self._ensure_catalog()
197
+ except Exception as error:
198
+ return _error_result(str(error))
199
+
200
+ args = dict(raw_arguments or {})
201
+ code = args.pop(ENV_ARG, None)
202
+ if not code:
203
+ return _error_result(f'"{ENV_ARG}" is required. Choose one of: {", ".join(self.upstreams)}')
204
+ if code not in self.upstreams:
205
+ return _error_result(f'Unknown environment "{code}". Choose one of: {", ".join(self.upstreams)}')
206
+
207
+ # The write gate runs before anything that could touch the network
208
+ # (the offer check below is local; revalidation is not) so a refused
209
+ # write to an unverified environment never sends it a single request.
210
+ environment = next(env for env in self.config.environments if env.code == code)
211
+ blocked = policy.blocked_write(
212
+ name, args, code=code, writable=environment.writable, extra_read_only=self.config.extra_read_only
213
+ )
214
+ if blocked:
215
+ return _error_result(blocked)
216
+
217
+ offered = self.env_by_tool.get(name)
218
+ if offered is not None and code not in offered:
219
+ return _error_result(f'[{code}] does not offer "{name}" (missing there, or its arguments differ).')
220
+
221
+ if code in self.unverified:
222
+ revalidation_error = await self._revalidate(code, name)
223
+ if revalidation_error is not None:
224
+ return revalidation_error
225
+
226
+ try:
227
+ return await self.upstreams[code].call_tool(name, args)
228
+ except Exception as error:
229
+ # Prefix so a per-environment permission failure never reads as a router bug.
230
+ message = str(error)
231
+ return _error_result(message if message.startswith(f"[{code}]") else f"[{code}] {message}")
232
+
233
+
234
+ def build_router(
235
+ *,
236
+ profiles: dict[str, Profile],
237
+ writable_codes: frozenset[str],
238
+ catalog_source: str,
239
+ extra_read_only: frozenset[str],
240
+ timeout: float,
241
+ ) -> Router:
242
+ environments = [
243
+ EnvironmentSpec(code=code, label=profile["name"], writable=code in writable_codes)
244
+ for code, profile in profiles.items()
245
+ ]
246
+ upstreams = {code: Upstream(code, profile, timeout=timeout) for code, profile in profiles.items()}
247
+ config = RouterConfig(environments=environments, catalog_source=catalog_source, extra_read_only=extra_read_only)
248
+ return Router(config, upstreams, catalog_retry_window=_catalog_retry_window_seconds())
249
+
250
+
251
+ async def _on_list_tools(
252
+ router: Router, _ctx: ServerRequestContext[Any], _params: types.PaginatedRequestParams | None
253
+ ) -> types.ListToolsResult:
254
+ return types.ListToolsResult(tools=await router.list_tools())
255
+
256
+
257
+ async def _on_call_tool(
258
+ router: Router, _ctx: ServerRequestContext[Any], params: types.CallToolRequestParams
259
+ ) -> types.CallToolResult:
260
+ return await router.call_tool(params.name, params.arguments)
261
+
262
+
263
+ async def _serve(router: Router) -> None:
264
+ server: Server[Any] = Server(
265
+ SERVER_NAME,
266
+ version=SERVER_VERSION,
267
+ on_list_tools=lambda ctx, params: _on_list_tools(router, ctx, params),
268
+ on_call_tool=lambda ctx, params: _on_call_tool(router, ctx, params),
269
+ )
270
+ # Answer `initialize` immediately: the catalogue build runs as a background
271
+ # task so one hung factory reads as a slow tools/list, not a failed
272
+ # handshake (clients cap startup time).
273
+ async with stdio_server() as (read_stream, write_stream):
274
+ log("ready over stdio")
275
+ async with anyio.create_task_group() as task_group:
276
+ task_group.start_soon(router.prime_catalog)
277
+ await server.run(read_stream, write_stream, server.create_initialization_options())
278
+ # stdin EOF means the client is gone: don't make shutdown wait for
279
+ # a still-running catalogue build (up to --timeout) when nothing
280
+ # will ever read its result.
281
+ task_group.cancel_scope.cancel()
282
+
283
+
284
+ def run_server(router: Router) -> None:
285
+ anyio.run(_serve, router)
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any
5
+
6
+ import anyio
7
+ import httpx2
8
+ import mcp_types as types
9
+ from mcp.client import Client
10
+ from mcp.client.streamable_http import streamable_http_client
11
+
12
+ from if_cli.config import Profile
13
+ from if_cli.http import parse_url, url_origin
14
+ from if_cli.oauth import get_valid_token
15
+
16
+ if sys.version_info >= (3, 11):
17
+ _BaseExceptionGroup = BaseExceptionGroup # noqa: F821 - builtin from 3.11 on, guarded above
18
+ else: # pragma: no cover - exercised only on Python < 3.11
19
+ # anyio pulls in this backport for exactly that case, so it is always
20
+ # importable here when the builtin is not.
21
+ from exceptiongroup import BaseExceptionGroup as _BaseExceptionGroup # ty: ignore[unresolved-import]
22
+
23
+
24
+ def _leaf_exception(error: BaseException) -> BaseException:
25
+ """Unwrap nested single-member `BaseExceptionGroup`s down to their real cause.
26
+
27
+ anyio/httpx2 run request handling in task groups, so a failure inside
28
+ `ProfileBearerAuth` (e.g. the `CliError` login hint from `get_valid_token`)
29
+ reaches the caller wrapped in one or more `BaseExceptionGroup`s, and a plain
30
+ `str(error)` on those prints "unhandled errors in a TaskGroup" instead of the
31
+ real message. A group with several leaves has no single real cause, so those
32
+ are joined instead of picked from.
33
+ """
34
+ if isinstance(error, _BaseExceptionGroup):
35
+ leaves = [_leaf_exception(sub) for sub in error.exceptions]
36
+ if len(leaves) == 1:
37
+ return leaves[0]
38
+ return RuntimeError("; ".join(str(leaf) for leaf in leaves))
39
+ return error
40
+
41
+
42
+ class ProfileBearerAuth(httpx2.Auth):
43
+ """Injects a fresh `if-cli` bearer token per request; re-mints once on a 401.
44
+
45
+ Never sends a profile's token to any origin but its own factory host —
46
+ the rule from AGENTS.md, made mechanical.
47
+ """
48
+
49
+ def __init__(self, profile: Profile) -> None:
50
+ self._profile = profile
51
+
52
+ async def async_auth_flow(self, request: httpx2.Request): # noqa: ANN201 - httpx2.Auth's own generator protocol
53
+ origin = url_origin(parse_url(str(request.url)))
54
+ if origin != self._profile["host"]:
55
+ raise RuntimeError(
56
+ f"refusing to send the '{self._profile['name']}' profile's bearer token to {origin}; "
57
+ f"its factory origin is {self._profile['host']}"
58
+ )
59
+ token = await anyio.to_thread.run_sync(get_valid_token, self._profile)
60
+ request.headers["Authorization"] = f"Bearer {token}"
61
+ response = yield request
62
+ if response.status_code == 401:
63
+ token = await anyio.to_thread.run_sync(lambda: get_valid_token(self._profile, force_refresh=True))
64
+ request.headers["Authorization"] = f"Bearer {token}"
65
+ yield request
66
+
67
+
68
+ class Upstream:
69
+ """One factory environment's `/mcp` endpoint, reached with its own `if-cli` profile.
70
+
71
+ The factory's streamable-HTTP transport is stateless, so every call opens
72
+ its own client session (connect, initialize, call, close) instead of
73
+ holding a long-lived connection open for the process lifetime. That keeps
74
+ this class small and avoids babysitting three persistent connections.
75
+ """
76
+
77
+ def __init__(self, code: str, profile: Profile, *, timeout: float) -> None:
78
+ self.code = code
79
+ self.profile = profile
80
+ self.timeout = timeout
81
+
82
+ async def _run(self, action):
83
+ url = f"{self.profile['host']}/mcp"
84
+ try:
85
+ async with httpx2.AsyncClient(auth=ProfileBearerAuth(self.profile), timeout=self.timeout) as http_client:
86
+ async with Client(streamable_http_client(url, http_client=http_client)) as client:
87
+ return await action(client)
88
+ except _BaseExceptionGroup as error:
89
+ raise _leaf_exception(error) from error
90
+
91
+ async def list_tools(self) -> list[dict[str, Any]]:
92
+ async def collect(client: Client) -> list[dict[str, Any]]:
93
+ collected: list[dict[str, Any]] = []
94
+ cursor: str | None = None
95
+ while True:
96
+ result = await client.list_tools(cursor=cursor)
97
+ collected.extend(
98
+ tool.model_dump(by_alias=True, mode="json", exclude_unset=True) for tool in result.tools
99
+ )
100
+ if not result.next_cursor:
101
+ return collected
102
+ cursor = result.next_cursor
103
+
104
+ return await self._run(collect)
105
+
106
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> types.CallToolResult:
107
+ return await self._run(lambda client: client.call_tool(name, arguments))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.0.2.dev16
3
+ Version: 1.0.3.dev19
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>
@@ -14,6 +14,12 @@ Classifier: License :: Other/Proprietary License
14
14
  Classifier: Programming Language :: Python :: 3 :: Only
15
15
  Classifier: Typing :: Typed
16
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'
17
23
  Description-Content-Type: text/markdown
18
24
 
19
25
  # insightfactory-cli
@@ -201,6 +207,15 @@ write to stdout, never launch a browser or block on interactive input, and only
201
207
  use bounded waits. Changing or removing any of them is a breaking change and
202
208
  needs a major version bump.
203
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
+
204
219
  ## How authentication works
205
220
 
206
221
  - Profiles live in `~/.insightfactory/config`, one per customer and environment.
@@ -219,6 +234,76 @@ needs a major version bump.
219
234
  the client ID from the factory's discovery document at runtime and holds no
220
235
  client secret.
221
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
+
222
307
  ## Development
223
308
 
224
309
  ```bash
@@ -236,7 +321,9 @@ uv run if-cli --version
236
321
  INSIGHTFACTORY_CONFIG_DIR=$(mktemp -d) uv run if-cli profiles
237
322
  ```
238
323
 
239
- 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.
240
327
 
241
328
  ## Release
242
329
 
@@ -1,24 +1,30 @@
1
1
  if_cli/__init__.py,sha256=YYcz-hEiXXUjdBFGipOrVHynsyH4ZLqQOCDobvCAu8Y,26
2
2
  if_cli/__main__.py,sha256=2hLFIiT-gocMb0VJVzVYtGVIoHrxyMChucRgexbXLSQ,68
3
3
  if_cli/cache.py,sha256=UfvHs3GQIVg0sVfKYWw4UGlq8d-8BEXuZIDfC1hwwjA,6587
4
- if_cli/cli.py,sha256=1pdYz3bhsNO120aWs9Fqjchj4UvNByn5Otk62IiBvzY,5832
4
+ if_cli/cli.py,sha256=fF7HvsUgpKhkLFdXLMJ1JXHGjp3RwJt2YmPSEW0d8gY,6270
5
5
  if_cli/colour.py,sha256=2hbaM4ubTHGja6YZRD886eqnB7T2IaZyW5rvfOjxe4M,682
6
- if_cli/config.py,sha256=IZhuo0cMsP-L9rLIKYVB87YRhCF-tcuyBTWp0-um4vM,6338
6
+ if_cli/config.py,sha256=YFnegm_-e051bL0vyTFIkVrKamjgY08DVCqK0VgFNvA,10129
7
7
  if_cli/constants.py,sha256=RW43KupAdhXfBsX6MxzZdJ-R57brX0q0TgIq8xXgvPs,269
8
8
  if_cli/http.py,sha256=W8HL3uZLeOu5MgmsOavM1Y6x4AcrInIQsAr5hx-j8AE,7671
9
- if_cli/main.py,sha256=kkJVfkRHvdcMLysmfsag7YVneDpJP02I0KRVdOaBh3k,2697
10
- if_cli/oauth.py,sha256=X0514sPBysjGwcN5_39KaQfQ62sbl72Xztixd9BqOE4,16076
9
+ if_cli/main.py,sha256=pn-X2wrHNplc8ceT5_nEv5XlrTN3KgpK8UU4gAmBiWY,3212
10
+ if_cli/oauth.py,sha256=8hlTj8k01jXWtx_D5RrwSKamYJE9CD7DDwq4Dqc28MU,16535
11
11
  if_cli/runtime.py,sha256=Pz2iVclqy-uu1UMQWK2P_Hx7O4zAF30UpSFqQvGN0NY,2663
12
12
  if_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  if_cli/commands/api.py,sha256=XeVhhD39Oho4xNbMJIGh1I6vi53on5EGGsAtsb_5TAg,9016
14
14
  if_cli/commands/config.py,sha256=1KAxjme897NIbXTGBw1rNJlXnFvhbEUf5MAli8A93ns,1724
15
15
  if_cli/commands/login.py,sha256=vxsYcRZWsaNEzekyN1zy4ROJO-OCGUpjaVUBhtbK1LY,3255
16
16
  if_cli/commands/logout.py,sha256=TW0P4_tXcTtiHHR73xVPHyhlbOWD1jBnkZsMImCiCBw,668
17
- if_cli/commands/profiles.py,sha256=JNBrYWdHQ9MHauwZxH33IAOYgRYRniX7bwd7KBT6Vyk,3869
17
+ if_cli/commands/mcp.py,sha256=nXc6qT-psryLDm8Yjmu_2IFDOEnNIAjCkCT5UTDZP4M,6553
18
+ if_cli/commands/profiles.py,sha256=tgZP5sMqzbFHXlfvglL2plsGdU0M3joo3wvQKX45J1U,3950
18
19
  if_cli/commands/set_token.py,sha256=BVyv_QmByvllr04pE2sz8JFEscHbIgOC-7ZPg61PRoc,1886
19
20
  if_cli/commands/token.py,sha256=CyiXkyH7vxgH91-n-XJzeHnLI6mZJwO9Js5AHvfHaeM,1120
20
- insightfactory_cli-1.0.2.dev16.dist-info/METADATA,sha256=Iq2H3FJKYrsSgrIHMnaXt1Nl4FuJUGv53lXQ0iTPTb0,13101
21
- insightfactory_cli-1.0.2.dev16.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
22
- insightfactory_cli-1.0.2.dev16.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
23
- insightfactory_cli-1.0.2.dev16.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
24
- insightfactory_cli-1.0.2.dev16.dist-info/RECORD,,
21
+ if_cli/router/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ if_cli/router/catalog.py,sha256=Er2ZbWTwDUVU-qBGCvyA0bIlFk3UYw_6sVUlYwBhMpI,4992
23
+ if_cli/router/policy.py,sha256=CclbjuOA5mwwoQiNDa17iQeN9eXRbfy11bS9JFd8gL4,2840
24
+ if_cli/router/server.py,sha256=kTQwvUVyco8QjOfjPHZF8LYUmHi8R5DdIk-Wxr5yHVA,12176
25
+ if_cli/router/upstream.py,sha256=qFWSQ0lDTXM_8Q7vq8X5sWP2cMWXx5Hn-WwiLL7r3UM,4631
26
+ insightfactory_cli-1.0.3.dev19.dist-info/METADATA,sha256=Q_3o-4eDdISScq_WWPvTq3oL93cGxIYJMJ1DDi4Ueqw,17509
27
+ insightfactory_cli-1.0.3.dev19.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
28
+ insightfactory_cli-1.0.3.dev19.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
29
+ insightfactory_cli-1.0.3.dev19.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
30
+ insightfactory_cli-1.0.3.dev19.dist-info/RECORD,,