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 +6 -0
- if_cli/commands/mcp.py +164 -0
- if_cli/commands/profiles.py +3 -1
- if_cli/config.py +86 -0
- if_cli/main.py +10 -0
- if_cli/oauth.py +12 -5
- if_cli/router/__init__.py +0 -0
- if_cli/router/catalog.py +132 -0
- if_cli/router/policy.py +86 -0
- if_cli/router/server.py +285 -0
- if_cli/router/upstream.py +107 -0
- {insightfactory_cli-1.0.2.dev16.dist-info → insightfactory_cli-1.0.3.dev19.dist-info}/METADATA +89 -2
- {insightfactory_cli-1.0.2.dev16.dist-info → insightfactory_cli-1.0.3.dev19.dist-info}/RECORD +16 -10
- {insightfactory_cli-1.0.2.dev16.dist-info → insightfactory_cli-1.0.3.dev19.dist-info}/WHEEL +0 -0
- {insightfactory_cli-1.0.2.dev16.dist-info → insightfactory_cli-1.0.3.dev19.dist-info}/entry_points.txt +0 -0
- {insightfactory_cli-1.0.2.dev16.dist-info → insightfactory_cli-1.0.3.dev19.dist-info}/licenses/LICENSE +0 -0
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)
|
if_cli/commands/profiles.py
CHANGED
|
@@ -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(
|
|
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
|
if_cli/router/catalog.py
ADDED
|
@@ -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
|
if_cli/router/policy.py
ADDED
|
@@ -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
|
+
)
|
if_cli/router/server.py
ADDED
|
@@ -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))
|
{insightfactory_cli-1.0.2.dev16.dist-info → insightfactory_cli-1.0.3.dev19.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: insightfactory-cli
|
|
3
|
-
Version: 1.0.
|
|
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.
|
|
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
|
|
{insightfactory_cli-1.0.2.dev16.dist-info → insightfactory_cli-1.0.3.dev19.dist-info}/RECORD
RENAMED
|
@@ -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=
|
|
4
|
+
if_cli/cli.py,sha256=fF7HvsUgpKhkLFdXLMJ1JXHGjp3RwJt2YmPSEW0d8gY,6270
|
|
5
5
|
if_cli/colour.py,sha256=2hbaM4ubTHGja6YZRD886eqnB7T2IaZyW5rvfOjxe4M,682
|
|
6
|
-
if_cli/config.py,sha256=
|
|
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=
|
|
10
|
-
if_cli/oauth.py,sha256=
|
|
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/
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|