insightfactory-cli 1.1.2.dev28__py3-none-any.whl → 1.1.2.dev29__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/commands/mcp.py CHANGED
@@ -15,12 +15,15 @@ MCP_OPTIONS: Options = {
15
15
  "catalog": {"type": "string"},
16
16
  "allow-tool": {"type": "string", "repeat": True},
17
17
  "timeout": {"type": "string", "default": "120"},
18
+ "skills": {"type": "boolean"},
19
+ "no-skills": {"type": "boolean"},
18
20
  }
19
21
 
20
22
  MCP_USAGE = (
21
23
  "usage: if-cli mcp [FACTORY] [--env CODE=PROFILE ...]\n"
22
24
  " [--writable CODE ... | --read-only] [--catalog CODE]\n"
23
25
  " [--allow-tool NAME ...] [--timeout SECONDS]\n"
26
+ " [--skills | --no-skills]\n"
24
27
  "\n"
25
28
  "Runs one MCP server over stdio that fronts several factory environments: every\n"
26
29
  "tool gets a required 'environment' argument, routed to that environment's own\n"
@@ -29,7 +32,9 @@ MCP_USAGE = (
29
32
  "section. Without FACTORY, --env is required at least once. --catalog defaults\n"
30
33
  "to the first environment; environments not passed to --writable are read-only;\n"
31
34
  "--read-only forces every environment read-only and cannot be combined with\n"
32
- "--writable.\n"
35
+ "--writable. --skills advertises the experimental skills extension; the\n"
36
+ "catalog environment's skill:// resources are served either way. It is off\n"
37
+ "unless the factory section says 'skills = true' or --skills is passed.\n"
33
38
  )
34
39
 
35
40
  MISSING_MCP_EXTRA = (
@@ -91,6 +96,9 @@ def mcp_command(argv: list[str]) -> None:
91
96
  if read_only and raw_writable is not None:
92
97
  die(f"--read-only cannot be combined with --writable\n\n{MCP_USAGE}")
93
98
 
99
+ if values["skills"] and values["no-skills"]:
100
+ die(f"--skills cannot be combined with --no-skills\n\n{MCP_USAGE}")
101
+
94
102
  config = load_config()
95
103
 
96
104
  # A dict, not a list of pairs: --env "adds or replaces that code's profile"
@@ -102,12 +110,14 @@ def mcp_command(argv: list[str]) -> None:
102
110
  mappings: dict[str, str] = {}
103
111
  writable_codes: frozenset[str] = frozenset()
104
112
  catalog_source: str | None = None
113
+ skills = False
105
114
 
106
115
  if factory_name is not None:
107
116
  factory = parse_factory_section(config, factory_name)
108
117
  mappings.update(factory["environments"])
109
118
  writable_codes = factory["writable"]
110
119
  catalog_source = factory["catalog"]
120
+ skills = factory["skills"]
111
121
 
112
122
  for raw in raw_envs:
113
123
  code, profile_name = _parse_env_mapping(raw)
@@ -125,6 +135,10 @@ def mcp_command(argv: list[str]) -> None:
125
135
  writable_codes = frozenset(raw_writable)
126
136
  if values["catalog"] is not None:
127
137
  catalog_source = values["catalog"]
138
+ if values["skills"]:
139
+ skills = True
140
+ elif values["no-skills"]:
141
+ skills = False
128
142
  if catalog_source is None:
129
143
  catalog_source = codes[0]
130
144
 
@@ -153,7 +167,7 @@ def mcp_command(argv: list[str]) -> None:
153
167
  f"{code}={mappings[code]} {profiles[code]['host']}{' (writable)' if code in writable_codes else ''}"
154
168
  for code in codes
155
169
  ]
156
- log(f"{label}: {'; '.join(parts)}; catalog={catalog_source}")
170
+ log(f"{label}: {'; '.join(parts)}; catalog={catalog_source}; skills={'on' if skills else 'off'}")
157
171
 
158
172
  router = build_router(
159
173
  profiles=profiles,
@@ -161,5 +175,6 @@ def mcp_command(argv: list[str]) -> None:
161
175
  catalog_source=catalog_source,
162
176
  extra_read_only=extra_read_only,
163
177
  timeout=timeout,
178
+ skills=skills,
164
179
  )
165
180
  run_server(router)
if_cli/config.py CHANGED
@@ -28,10 +28,13 @@ class Factory(TypedDict):
28
28
  environments: list[tuple[str, str]]
29
29
  writable: frozenset[str]
30
30
  catalog: str
31
+ skills: bool
31
32
 
32
33
 
33
34
  FACTORY_SECTION_PREFIX = "factory "
34
- FACTORY_RESERVED_KEYS = {"writable", "catalog"}
35
+ FACTORY_RESERVED_KEYS = {"writable", "catalog", "skills"}
36
+ FACTORY_TRUE = {"true", "yes", "on", "1"}
37
+ FACTORY_FALSE = {"false", "no", "off", "0", ""}
35
38
 
36
39
 
37
40
  def config_dir() -> str:
@@ -217,7 +220,18 @@ def parse_factory_section(config: dict[str, dict[str, str]], name: str) -> Facto
217
220
  catalog_value = section.get("catalog")
218
221
  catalog = catalog_value.strip() if catalog_value is not None and catalog_value.strip() else codes[0]
219
222
 
220
- return {"name": name, "environments": environments, "writable": writable, "catalog": catalog}
223
+ skills_value = section.get("skills", "").strip().lower()
224
+ if skills_value not in FACTORY_TRUE and skills_value not in FACTORY_FALSE:
225
+ die(f"factory '{name}' in {config_file()}: skills must be true or false (found '{skills_value}')")
226
+ skills = skills_value in FACTORY_TRUE
227
+
228
+ return {
229
+ "name": name,
230
+ "environments": environments,
231
+ "writable": writable,
232
+ "catalog": catalog,
233
+ "skills": skills,
234
+ }
221
235
 
222
236
 
223
237
  def get_factory(config: dict[str, dict[str, str]], name: str) -> Factory:
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping, Sequence
5
+ from typing import Any
6
+ from urllib.parse import unquote, unquote_plus, urlsplit, urlunsplit
7
+
8
+ from if_cli.router.catalog import ENV_ARG, EnvironmentSpec
9
+
10
+ # RFC 6570 query expansion. A client that expands the published template fills
11
+ # `environment` like any other variable, so selecting the factory reads the same
12
+ # way for a resource as the spliced enum does for a tool. Form style opens a
13
+ # query with `?`; continuation style adds to one that is already open, and a
14
+ # template carrying its own query needs the second (see `scope_template`).
15
+ TEMPLATE_SUFFIX = "{?" + ENV_ARG + "}"
16
+ TEMPLATE_CONTINUATION = "{&" + ENV_ARG + "}"
17
+
18
+ # The variable expression inside a URI template, for reading its variable list
19
+ # and for turning the template into a matcher.
20
+ _EXPRESSION = re.compile(r"\{[^}]*\}")
21
+
22
+ # The operator an expression may open with, and the modifiers a varspec may
23
+ # carry: `{+path}`, `{?a,b}`, `{#frag}`, `{x:3}`, `{y*}`.
24
+ _OPERATORS = "+#./;?&=,!@|"
25
+
26
+ # An expression that expands into the fragment, where a `?` is no longer a query.
27
+ _FRAGMENT = re.compile(r"\{#[^}]*\}")
28
+
29
+ # An expression that continues a query rather than opening one.
30
+ _CONTINUATION = re.compile(r"\{&[^}]*\}")
31
+
32
+
33
+ class ContentError(Exception):
34
+ """A routing decision this router cannot make, reported to the client as an error.
35
+
36
+ Distinct from `CliError`: that ends the process at startup, this one answers
37
+ a single request and leaves the server running.
38
+ """
39
+
40
+
41
+ def _variable_names(expression: str) -> list[str]:
42
+ """The variable names one RFC 6570 expression declares.
43
+
44
+ `{?deploy.environment}` declares `deploy.environment`, not `environment`:
45
+ a dot is legal in a varname, so matching on a word boundary would refuse a
46
+ template that never names ours. Modifiers are stripped, because `{x:3}` and
47
+ `{x*}` are both the variable `x`.
48
+
49
+ Names are decoded before they are compared, because a varname may be
50
+ percent-encoded and `split_environment` decodes the field name it reads.
51
+ The two have to agree about that, or a name claims our field without
52
+ tripping the guard.
53
+ """
54
+ body = expression[1:-1].lstrip(_OPERATORS)
55
+ return [unquote(name.split(":", 1)[0].rstrip("*")) for name in body.split(",") if name]
56
+
57
+
58
+ def _literal_query_names(uri_template: str) -> list[str]:
59
+ """The names of the query fields a template spells out for itself.
60
+
61
+ `if://search?environment=fixed{&q}` declares no variable at all, so reading
62
+ the expressions alone would miss that it has already taken our field.
63
+ """
64
+ stem = _EXPRESSION.sub("", _split_fragment(uri_template)[0])
65
+ # Split on both delimiters: `if://x{?a}&environment=fixed` opens its query
66
+ # inside an expression, so partitioning on `?` alone would see no query at
67
+ # all. Neither character can appear in a path, so the first segment is the
68
+ # only one that is not a field.
69
+ return [unquote_plus(field.split("=", 1)[0]) for field in re.split(r"[?&]", stem)[1:] if field]
70
+
71
+
72
+ def declares_environment(uri_template: str) -> bool:
73
+ if ENV_ARG in _literal_query_names(uri_template):
74
+ return True
75
+ return any(ENV_ARG in _variable_names(expression) for expression in _EXPRESSION.findall(uri_template))
76
+
77
+
78
+ def _environment_list(environments: Sequence[EnvironmentSpec]) -> str:
79
+ return "; ".join(f"{env.code} = {env.label}{'' if env.writable else ' (read-only)'}" for env in environments)
80
+
81
+
82
+ def split_environment(uri: str) -> tuple[str, str | None]:
83
+ """Peel `?environment=<code>` off a resource URI, returning the rest and the code.
84
+
85
+ Only this router's own parameter is removed, and every other field is
86
+ carried across as the exact bytes the client sent: the URI belongs to the
87
+ factory, which may parse or sign it, so decoding and re-encoding it would
88
+ hand upstream something the client never wrote. Only the field *name* is
89
+ decoded, and only to compare it.
90
+
91
+ A blank `environment=` is what an RFC 6570 client emits when it expands the
92
+ variable with nothing, so it counts as unset, but it is still stripped: it
93
+ is our field either way and the factory has no use for it.
94
+ """
95
+ parts = urlsplit(uri)
96
+ if not parts.query:
97
+ return uri, None
98
+ fields = parts.query.split("&")
99
+ # Last wins, and exactly one field is ours. The router appends its own
100
+ # variable after whatever the factory published, so when a URI carries two
101
+ # the later one is the client's and the earlier one belongs to the factory:
102
+ # removing both would delete a field this module exists to forward intact.
103
+ ours = [at for at, field in enumerate(fields) if unquote_plus(field.split("=", 1)[0]) == ENV_ARG]
104
+ if not ours:
105
+ return uri, None
106
+ mine = ours[-1]
107
+ _, _, value = fields[mine].partition("=")
108
+ kept = [field for at, field in enumerate(fields) if at != mine]
109
+ bare = urlunsplit((parts.scheme, parts.netloc, parts.path, "&".join(kept), parts.fragment))
110
+ # The blank is read after the pick, not used to choose: a client that
111
+ # expanded the variable with nothing has said "unset", and saying it last
112
+ # does not hand the choice back to an earlier field.
113
+ return bare, unquote_plus(value) if value else None
114
+
115
+
116
+ def scope_template(template: Mapping[str, Any], environments: Sequence[EnvironmentSpec]) -> dict[str, Any]:
117
+ """Publish one resource template with `environment` appended as a variable.
118
+
119
+ A template is the only resource shape whose contents are per-environment:
120
+ `if://task-config-schemas/{taskType}` resolves to that factory's own
121
+ activity catalogue. Concrete resources are not scoped this way (see
122
+ `resolve_resource`), because `skill://` bodies cross-reference each other by
123
+ bare URI and rewriting them would leave those links pointing at nothing.
124
+ """
125
+ uri_template = template.get("uriTemplate") or ""
126
+ if not uri_template:
127
+ raise ContentError("resource template has no uriTemplate.")
128
+ if declares_environment(uri_template):
129
+ raise ContentError(
130
+ f'resource template "{uri_template}" already declares an "{ENV_ARG}" variable, which the router '
131
+ "reserves to select the factory environment."
132
+ )
133
+ # A template that already opens a query gets the continuation operator;
134
+ # a second `{?...}` would expand to a second literal `?` and the parameter
135
+ # would be read as part of the previous value.
136
+ stem, fragment = _split_fragment(uri_template)
137
+ suffix = TEMPLATE_CONTINUATION if _opens_a_query(stem) else TEMPLATE_SUFFIX
138
+ description = (template.get("description") or "").rstrip()
139
+ note = f"Set {ENV_ARG} to choose which factory environment answers: {_environment_list(environments)}."
140
+ return {
141
+ **template,
142
+ "uriTemplate": f"{stem}{suffix}{fragment}",
143
+ "description": f"{description}\n\n{note}" if description else note,
144
+ }
145
+
146
+
147
+ def _split_fragment(uri_template: str) -> tuple[str, str]:
148
+ """Split a template before whatever expands into its fragment.
149
+
150
+ Everything after a `#` is the fragment, where a `?` is an ordinary
151
+ character rather than a query opener, so the router's own variable has to
152
+ go in front of it or it never reaches `split_environment`.
153
+ """
154
+ match = _FRAGMENT.search(uri_template)
155
+ cuts = [at for at in (match.start() if match else -1, uri_template.find("#")) if at >= 0]
156
+ if not cuts:
157
+ return uri_template, ""
158
+ cut = min(cuts)
159
+ return uri_template[:cut], uri_template[cut:]
160
+
161
+
162
+ def _opens_a_query(uri_template: str) -> bool:
163
+ """Whether expanding this template can already have produced a `?`.
164
+
165
+ `{&x}` counts only alongside a reserved expansion, which is the one way a
166
+ query can be open without the template text saying so. On its own, `{&x}`
167
+ with no `?` anywhere is a template whose expansion has no query at all, and
168
+ there form style is right: it opens one rather than continuing it, so
169
+ `if://x{&q}{?environment}` expands to `if://x&q=1?environment=prd`, which
170
+ routes and hands the factory back exactly what its own template produces.
171
+ """
172
+ reduced = _EXPRESSION.sub(lambda match: match.group(0)[:2], uri_template)
173
+ return "?" in reduced or ("{&" in reduced and "{+" in reduced)
174
+
175
+
176
+ def unscope_template(uri_template: str) -> str:
177
+ """Remove the variable `scope_template` added, wherever it was put.
178
+
179
+ Not a suffix strip: a template with a fragment carries it in the middle,
180
+ and the factory has never heard of it in any position.
181
+ """
182
+ return uri_template.replace(TEMPLATE_SUFFIX, "", 1).replace(TEMPLATE_CONTINUATION, "", 1)
183
+
184
+
185
+ def template_matcher(uri_template: str) -> re.Pattern[str]:
186
+ """A matcher for URIs that are expansions of `uri_template`.
187
+
188
+ Deliberately loose: every variable expression becomes "any characters,
189
+ including none", because an undefined RFC 6570 variable expands to nothing
190
+ and `{id}{?format}` with no format is still an expansion. It is only ever
191
+ asked whether a URI *could* be one, and being loose makes it fail towards
192
+ refusing to guess an environment rather than towards silently answering
193
+ from the wrong factory.
194
+
195
+ A template of nothing but variables matches every URI, so it discriminates
196
+ nothing and is given a matcher that matches nothing instead. That guard is
197
+ for "matches literally everything", not for "too loose to be useful":
198
+ `if://{path}` keeps its scheme literal and so still claims the whole
199
+ `if://` space, which makes every static `if://` resource need an
200
+ environment. Refusing is the safe direction, but it is a real cost.
201
+
202
+ A reserved expansion (`{+var}`, `{#var}`) can expand into `?` and `#`
203
+ itself, so whether a URI opens a query is not decidable from the template
204
+ text. Everything here is a heuristic over the literal characters.
205
+
206
+ The matcher is built from the catalogue source's templates alone, so a
207
+ template only another environment publishes is invisible here. A client can
208
+ only learn of one out of band, since `resources/templates/list` publishes
209
+ the catalogue source's, so the gap is narrow; it is the reason this is a
210
+ routing hint rather than a guarantee.
211
+ """
212
+ literals = _EXPRESSION.split(uri_template)
213
+ if not any(literals):
214
+ return re.compile(r"(?!)")
215
+ return re.compile(".*".join(re.escape(literal) for literal in literals))
216
+
217
+
218
+ def resolve_resource(
219
+ uri: str,
220
+ *,
221
+ templates: Sequence[str],
222
+ catalog_source: str,
223
+ codes: Sequence[str],
224
+ reserved: Sequence[str] = (),
225
+ ) -> tuple[str, str]:
226
+ """Which environment answers `uri`, and the URI to ask it for.
227
+
228
+ Two cases, and the URI itself says which. A template expansion is
229
+ per-environment content (`if://task-config-schemas/AZSQL` is that factory's
230
+ own activity catalogue), so it is read from the environment its query names
231
+ and refused without one rather than answered from the wrong factory.
232
+ Anything else is static content the release publishes identically
233
+ everywhere (the `skill://` bodies), so it is read from the catalogue source.
234
+
235
+ Membership of `resources/list` is deliberately not the test: a skill body
236
+ may link to a sibling document the factory never advertised, and such a link
237
+ carries no `environment` and could not be given one.
238
+ """
239
+ bare, chosen = split_environment(uri)
240
+ if chosen is not None:
241
+ if chosen not in codes:
242
+ # A template this router refused to publish takes the field for
243
+ # itself, so what looks like a bad environment code is the
244
+ # factory's own value. The operator reads that on stderr when the
245
+ # listing skips it; the client deserves the same sentence.
246
+ claimed = next((template for template in reserved if template_matcher(template).fullmatch(uri)), None)
247
+ if claimed is not None:
248
+ raise ContentError(
249
+ f'"{uri}" expands the resource template "{claimed}", which takes the "{ENV_ARG}" field for '
250
+ "itself. This router reserves that field to select the factory environment, so it cannot "
251
+ "route this template and does not publish it."
252
+ )
253
+ raise ContentError(f'Unknown environment "{chosen}". Choose one of: {", ".join(codes)}')
254
+ return bare, chosen
255
+ matched = next((template for template in templates if template_matcher(template).fullmatch(bare)), None)
256
+ if matched is not None:
257
+ raise ContentError(
258
+ f'"{bare}" expands the resource template "{matched}", whose contents differ per factory, so it needs '
259
+ f"an environment: append ?{ENV_ARG}=<code> to the URI. Choose one of: {', '.join(codes)}"
260
+ )
261
+ return bare, catalog_source
262
+
263
+
264
+ def splice_prompt_environment(prompt: Mapping[str, Any], environments: Sequence[EnvironmentSpec]) -> dict[str, Any]:
265
+ """Publish one prompt with `environment` added as a required argument."""
266
+ arguments = [dict(argument) for argument in (prompt.get("arguments") or [])]
267
+ if any(argument.get("name") == ENV_ARG for argument in arguments):
268
+ raise ContentError(
269
+ f'prompt "{prompt.get("name")}" already declares an "{ENV_ARG}" argument, which the router reserves '
270
+ "to select the factory environment."
271
+ )
272
+ arguments.append(
273
+ {
274
+ "name": ENV_ARG,
275
+ "description": f"Which factory environment to run this against. {_environment_list(environments)}.",
276
+ "required": True,
277
+ }
278
+ )
279
+ return {**prompt, "arguments": arguments}
280
+
281
+
282
+ def environment_instruction(code: str, label: str) -> str:
283
+ """The line the router prepends to a rendered prompt.
284
+
285
+ The factory writes its prompts for a client talking to one factory, so they
286
+ name tools without an environment. Through the router every one of those
287
+ calls needs the argument, and a model following the prompt verbatim would
288
+ omit it on the first call and be told off for it.
289
+
290
+ Prepended rather than appended, and attributed: the factory's own last turn
291
+ may be an assistant prefill, which a trailing user turn would silently end,
292
+ and a bare instruction in the factory's voice is indistinguishable from the
293
+ prompt it is wrapping.
294
+ """
295
+ return (
296
+ f'[if-cli router] Every tool call while following this prompt must pass {ENV_ARG}="{code}" ({label}). '
297
+ "This router fronts several factory environments and the instructions below do not name one."
298
+ )
if_cli/router/server.py CHANGED
@@ -1,19 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ from collections.abc import Awaitable
4
5
  from dataclasses import dataclass
5
6
  from importlib.metadata import version
6
- from typing import Any
7
+ from typing import Any, TypeVar
7
8
 
8
9
  import anyio
9
10
  import mcp_types as types
10
11
  from mcp.server import Server
11
12
  from mcp.server.context import ServerRequestContext
12
13
  from mcp.server.stdio import stdio_server
14
+ from mcp.shared.exceptions import MCPError
13
15
 
14
16
  from if_cli.config import Profile
15
- from if_cli.router import catalog, policy
17
+ from if_cli.router import catalog, content, policy
16
18
  from if_cli.router.catalog import ENV_ARG, EnvironmentSpec
19
+ from if_cli.router.content import ContentError
17
20
  from if_cli.router.log import log
18
21
  from if_cli.router.upstream import Upstream
19
22
  from if_cli.runtime import CliError
@@ -21,8 +24,18 @@ from if_cli.runtime import CliError
21
24
  SERVER_NAME = "if-cli-mcp-router"
22
25
  SERVER_VERSION = version("insightfactory-cli")
23
26
 
27
+ # SEP-2640, still experimental; the factory gates its own half behind
28
+ # Mcp:ExperimentalSkills.
29
+ SKILLS_EXTENSION = "io.modelcontextprotocol/skills"
30
+
24
31
  DEFAULT_CATALOG_RETRY_WINDOW_S = 5.0
25
32
 
33
+ T = TypeVar("T")
34
+
35
+
36
+ def _tag(code: str, message: str) -> str:
37
+ return message if message.startswith(f"[{code}]") else f"[{code}] {message}"
38
+
26
39
 
27
40
  def _catalog_retry_window_seconds() -> float:
28
41
  """Read the catalogue-rebuild retry window, in milliseconds, from the environment.
@@ -41,6 +54,20 @@ def _catalog_retry_window_seconds() -> float:
41
54
  return value / 1000.0 if value > 0 else DEFAULT_CATALOG_RETRY_WINDOW_S
42
55
 
43
56
 
57
+ def _restore_uri(result: types.ReadResourceResult, target: str, uri: str) -> types.ReadResourceResult:
58
+ """Give the contents back the URI the client asked for.
59
+
60
+ `ResourceContents.uri` is how a client identifies what it just read, and
61
+ the same template expansion read from two environments would otherwise come
62
+ back under one identity, cacheable as one document. Only the entry for the
63
+ URI we rewrote is restored; anything else the factory returned is its own.
64
+ """
65
+ contents = [
66
+ block.model_copy(update={"uri": uri}) if str(block.uri) == target else block for block in result.contents
67
+ ]
68
+ return result.model_copy(update={"contents": contents})
69
+
70
+
44
71
  def _error_result(text: str) -> types.CallToolResult:
45
72
  return types.CallToolResult(content=[types.TextContent(text=text)], is_error=True)
46
73
 
@@ -50,6 +77,7 @@ class RouterConfig:
50
77
  environments: list[EnvironmentSpec]
51
78
  catalog_source: str
52
79
  extra_read_only: frozenset[str]
80
+ skills: bool = False
53
81
 
54
82
 
55
83
  class Router:
@@ -79,6 +107,12 @@ class Router:
79
107
  self._catalog_retry_window = catalog_retry_window
80
108
  self._catalog_error: Exception | None = None
81
109
  self._catalog_error_at: float = float("-inf")
110
+ self.templates: list[str] | None = None
111
+ # Items the listing had to skip, kept so the surface that can still be
112
+ # asked for one says why rather than failing obscurely.
113
+ self.reserved_templates: list[str] = []
114
+ self.unroutable_prompts: set[str] = set()
115
+ self._templates_lock = anyio.Lock()
82
116
 
83
117
  def _label(self, code: str) -> str:
84
118
  return next(env.label for env in self.config.environments if env.code == code)
@@ -166,8 +200,7 @@ class Router:
166
200
  try:
167
201
  remote = await self.upstreams[code].list_tools()
168
202
  except Exception as error:
169
- message = str(error)
170
- return _error_result(message if message.startswith(f"[{code}]") else f"[{code}] {message}")
203
+ return _error_result(_tag(code, str(error)))
171
204
 
172
205
  self.catalogues[code] = remote
173
206
  self.unverified.discard(code)
@@ -223,8 +256,177 @@ class Router:
223
256
  return await self.upstreams[code].call_tool(name, args)
224
257
  except Exception as error:
225
258
  # Prefix so a per-environment permission failure never reads as a router bug.
226
- message = str(error)
227
- return _error_result(message if message.startswith(f"[{code}]") else f"[{code}] {message}")
259
+ return _error_result(_tag(code, str(error)))
260
+
261
+ # ── resources, prompts and completion ────────────────────────────────────
262
+ #
263
+ # Tools are merged across every environment because the same tool exists on
264
+ # each one. The rest of the surface is not merged: `skill://` bodies are
265
+ # release-scoped documentation that cross-reference each other by bare URI,
266
+ # so they are served from the catalogue source as published, and only a
267
+ # resource TEMPLATE (whose contents really are per-factory) carries the
268
+ # `environment` variable. See `router.content`.
269
+
270
+ def _source(self) -> Upstream:
271
+ return self.upstreams[self.config.catalog_source]
272
+
273
+ async def _fetch_templates(self) -> list[dict[str, Any]]:
274
+ templates = await self._prefixed(self.config.catalog_source, self._source().list_resource_templates())
275
+ self.templates = [str(template["uriTemplate"]) for template in templates if template.get("uriTemplate")]
276
+ return templates
277
+
278
+ async def _templates_for_routing(self) -> list[dict[str, Any]]:
279
+ """`_fetch_templates`, but a factory that serves no templates is not an error.
280
+
281
+ `resources/templates/list` is the least-implemented half of the
282
+ resources capability, and a `-32601` from it must not take down a read
283
+ of a static `skill://` body: no templates means every URI is static,
284
+ which is exactly how this routed before templates existed.
285
+ """
286
+ try:
287
+ return await self._fetch_templates()
288
+ except MCPError as error:
289
+ if error.code != types.METHOD_NOT_FOUND:
290
+ raise
291
+ log(f"{self.config.catalog_source} serves no resources/templates/list; routing every URI as static")
292
+ self.templates = []
293
+ return []
294
+
295
+ async def _ensure_templates(self) -> list[str]:
296
+ """Learn which URIs are template expansions before routing a read.
297
+
298
+ Only the branch that has no `environment` needs this, and it needs the
299
+ published templates rather than the published resources: a `skill://`
300
+ body may link to a sibling the factory never advertised, and that link
301
+ is still static content the catalogue source can serve. Fetched once
302
+ and kept, because a template list changes only with a release.
303
+ """
304
+ async with self._templates_lock:
305
+ if self.templates is None:
306
+ await self._templates_for_routing()
307
+ return self.templates or []
308
+
309
+ async def list_resources(self) -> list[types.Resource]:
310
+ resources = await self._prefixed(self.config.catalog_source, self._source().list_resources())
311
+ return [types.Resource.model_validate(resource) for resource in resources]
312
+
313
+ async def list_resource_templates(self) -> list[types.ResourceTemplate]:
314
+ async with self._templates_lock:
315
+ templates = await self._fetch_templates()
316
+ self.reserved_templates = [
317
+ str(template["uriTemplate"])
318
+ for template in templates
319
+ if template.get("uriTemplate") and content.declares_environment(str(template["uriTemplate"]))
320
+ ]
321
+ return [
322
+ types.ResourceTemplate.model_validate(scoped)
323
+ for scoped in self._each(content.scope_template, templates, "resource template")
324
+ ]
325
+
326
+ async def read_resource(self, uri: str) -> types.ReadResourceResult:
327
+ # `split_environment` is pure and local, so a read that names its
328
+ # environment never waits on, or fails with, the catalogue source.
329
+ templates = [] if content.split_environment(uri)[1] is not None else await self._ensure_templates()
330
+ target, code = content.resolve_resource(
331
+ uri,
332
+ templates=templates,
333
+ catalog_source=self.config.catalog_source,
334
+ codes=list(self.upstreams),
335
+ reserved=self.reserved_templates,
336
+ )
337
+ result = await self._prefixed(code, self.upstreams[code].read_resource(target))
338
+ return result if target == uri else _restore_uri(result, target, uri)
339
+
340
+ async def list_prompts(self) -> list[types.Prompt]:
341
+ prompts = await self._prefixed(self.config.catalog_source, self._source().list_prompts())
342
+ spliced = self._each(content.splice_prompt_environment, prompts, "prompt")
343
+ kept = {prompt.get("name") for prompt in spliced}
344
+ self.unroutable_prompts = {str(prompt["name"]) for prompt in prompts if prompt.get("name") not in kept}
345
+ return [types.Prompt.model_validate(prompt) for prompt in spliced]
346
+
347
+ async def get_prompt(self, name: str, raw_arguments: dict[str, str] | None) -> types.GetPromptResult:
348
+ args = dict(raw_arguments or {})
349
+ if name in self.unroutable_prompts:
350
+ # The listing skipped it, but a client holding a cached list can
351
+ # still ask. Routing eats the `environment` argument, so forwarding
352
+ # it would silently deprive the factory of its own.
353
+ raise ContentError(
354
+ f'prompt "{name}" declares its own "{ENV_ARG}" argument, which this router reserves to select the '
355
+ "factory environment, so it cannot route it and does not publish it."
356
+ )
357
+ code = self._required_environment(args.pop(ENV_ARG, None))
358
+ result = await self._prefixed(code, self.upstreams[code].get_prompt(name, args))
359
+ instruction = types.PromptMessage(
360
+ role="user",
361
+ content=types.TextContent(text=content.environment_instruction(code, self._label(code))),
362
+ )
363
+ return result.model_copy(update={"messages": [instruction, *result.messages]})
364
+
365
+ async def complete(self, params: types.CompleteRequestParams) -> types.CompleteResult:
366
+ context_arguments = dict(params.context.arguments or {}) if params.context is not None else {}
367
+ chosen = context_arguments.pop(ENV_ARG, None)
368
+
369
+ if params.argument.name == ENV_ARG:
370
+ # The router owns this variable, so it answers for it rather than
371
+ # asking a factory about an argument it has never heard of.
372
+ values = [code for code in self.upstreams if code.startswith(params.argument.value or "")]
373
+ return types.CompleteResult(completion=types.Completion(values=values, total=len(values), has_more=False))
374
+
375
+ ref = params.ref
376
+ from_ref = None
377
+ if isinstance(ref, types.ResourceTemplateReference):
378
+ bare, from_ref = content.split_environment(ref.uri)
379
+ # Not a suffix strip: a template with a fragment carries the
380
+ # router's variable in the middle, and the factory has never heard
381
+ # of it in any position.
382
+ ref = ref.model_copy(update={"uri": content.unscope_template(bare)})
383
+ # A half-filled context argument is the normal state of a completion
384
+ # request, so a blank or half-typed `environment` falls back rather than
385
+ # refusing; an already-expanded ref that names one is the next-best
386
+ # answer. Completion degrades, it does not fail.
387
+ named = chosen or from_ref
388
+ code = named if named in self.upstreams else self.config.catalog_source
389
+ argument = {"name": params.argument.name, "value": params.argument.value}
390
+ return await self._prefixed(code, self.upstreams[code].complete(ref, argument, context_arguments or None))
391
+
392
+ def _each(self, splice, items: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]:
393
+ """Splice `environment` into each item, dropping the ones that cannot take it.
394
+
395
+ A factory item that already reserves the name is one item's problem.
396
+ Failing the whole call would answer a client that asked for twelve with
397
+ none of them, and say nothing about which one was at fault.
398
+ """
399
+ spliced = []
400
+ for item in items:
401
+ try:
402
+ spliced.append(splice(item, self.config.environments))
403
+ except ContentError as error:
404
+ log(f"skipping a {kind} from {self.config.catalog_source}: {error}")
405
+ return spliced
406
+
407
+ def _required_environment(self, code: str | None) -> str:
408
+ if not code:
409
+ raise ContentError(f'"{ENV_ARG}" is required. Choose one of: {", ".join(self.upstreams)}')
410
+ if code not in self.upstreams:
411
+ raise ContentError(f'Unknown environment "{code}". Choose one of: {", ".join(self.upstreams)}')
412
+ return code
413
+
414
+ async def _prefixed(self, code: str, awaitable: Awaitable[T]) -> T:
415
+ """Await an upstream call, tagging its failure with the environment it came from.
416
+
417
+ Same reason as the prefix in `call_tool`: a per-environment permission
418
+ failure or expired login must not read as a router bug.
419
+
420
+ A JSON-RPC error keeps its code. "no such prompt" and "the router
421
+ broke" are different answers, and a client that branches on the code
422
+ cannot tell them apart if everything arrives as INTERNAL_ERROR.
423
+ """
424
+ try:
425
+ return await awaitable
426
+ except MCPError as error:
427
+ raise MCPError(code=error.code, message=_tag(code, error.message), data=error.data) from error
428
+ except Exception as error:
429
+ raise RuntimeError(_tag(code, str(error))) from error
228
430
 
229
431
 
230
432
  def build_router(
@@ -234,13 +436,19 @@ def build_router(
234
436
  catalog_source: str,
235
437
  extra_read_only: frozenset[str],
236
438
  timeout: float,
439
+ skills: bool = False,
237
440
  ) -> Router:
238
441
  environments = [
239
442
  EnvironmentSpec(code=code, label=profile["name"], writable=code in writable_codes)
240
443
  for code, profile in profiles.items()
241
444
  ]
242
445
  upstreams = {code: Upstream(code, profile, timeout=timeout) for code, profile in profiles.items()}
243
- config = RouterConfig(environments=environments, catalog_source=catalog_source, extra_read_only=extra_read_only)
446
+ config = RouterConfig(
447
+ environments=environments,
448
+ catalog_source=catalog_source,
449
+ extra_read_only=extra_read_only,
450
+ skills=skills,
451
+ )
244
452
  return Router(config, upstreams, catalog_retry_window=_catalog_retry_window_seconds())
245
453
 
246
454
 
@@ -256,13 +464,95 @@ async def _on_call_tool(
256
464
  return await router.call_tool(params.name, params.arguments)
257
465
 
258
466
 
467
+ def _as_mcp_error(error: Exception) -> MCPError:
468
+ """Carry a router refusal to the client instead of losing it.
469
+
470
+ An ordinary exception out of a handler is logged and answered as a bare
471
+ "Internal server error" (`mcp.server.runner.modern_error_data`), which would
472
+ throw away the one sentence saying what to fix. `MCPError` reaches the wire
473
+ intact, so every refusal this router can explain is raised as one.
474
+
475
+ `tools/call` needs none of this: it has a result shape that carries
476
+ `is_error` with the text, which is why `call_tool` returns rather than raises.
477
+ """
478
+ if isinstance(error, ContentError):
479
+ return MCPError(code=types.INVALID_PARAMS, message=str(error))
480
+ return MCPError(code=types.INTERNAL_ERROR, message=str(error))
481
+
482
+
483
+ async def _guarded(action):
484
+ try:
485
+ return await action
486
+ except MCPError:
487
+ raise
488
+ except Exception as error:
489
+ raise _as_mcp_error(error) from error
490
+
491
+
492
+ async def _on_list_resources(
493
+ router: Router, _ctx: ServerRequestContext[Any], _params: types.PaginatedRequestParams | None
494
+ ) -> types.ListResourcesResult:
495
+ return types.ListResourcesResult(resources=await _guarded(router.list_resources()))
496
+
497
+
498
+ async def _on_list_resource_templates(
499
+ router: Router, _ctx: ServerRequestContext[Any], _params: types.PaginatedRequestParams | None
500
+ ) -> types.ListResourceTemplatesResult:
501
+ return types.ListResourceTemplatesResult(resource_templates=await _guarded(router.list_resource_templates()))
502
+
503
+
504
+ async def _on_read_resource(
505
+ router: Router, _ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams
506
+ ) -> types.ReadResourceResult:
507
+ return await _guarded(router.read_resource(params.uri))
508
+
509
+
510
+ async def _on_list_prompts(
511
+ router: Router, _ctx: ServerRequestContext[Any], _params: types.PaginatedRequestParams | None
512
+ ) -> types.ListPromptsResult:
513
+ return types.ListPromptsResult(prompts=await _guarded(router.list_prompts()))
514
+
515
+
516
+ async def _on_get_prompt(
517
+ router: Router, _ctx: ServerRequestContext[Any], params: types.GetPromptRequestParams
518
+ ) -> types.GetPromptResult:
519
+ return await _guarded(router.get_prompt(params.name, params.arguments))
520
+
521
+
522
+ async def _on_completion(
523
+ router: Router, _ctx: ServerRequestContext[Any], params: types.CompleteRequestParams
524
+ ) -> types.CompleteResult:
525
+ return await _guarded(router.complete(params))
526
+
527
+
528
+ def advertised_extensions(router: Router) -> dict[str, dict[str, Any]]:
529
+ """What this router claims to support at the handshake.
530
+
531
+ The handshake is answered before any factory has been reached (see
532
+ `prime_catalog`), so the router cannot ask the catalogue source whether it
533
+ serves skills in time to repeat the claim. An explicit opt-in is the honest
534
+ way to make it: `skills = true` in the factory section, or --skills.
535
+ """
536
+ return {SKILLS_EXTENSION: {}} if router.config.skills else {}
537
+
538
+
259
539
  async def _serve(router: Router) -> None:
260
540
  server: Server[Any] = Server(
261
541
  SERVER_NAME,
262
542
  version=SERVER_VERSION,
263
543
  on_list_tools=lambda ctx, params: _on_list_tools(router, ctx, params),
264
544
  on_call_tool=lambda ctx, params: _on_call_tool(router, ctx, params),
545
+ on_list_resources=lambda ctx, params: _on_list_resources(router, ctx, params),
546
+ on_list_resource_templates=lambda ctx, params: _on_list_resource_templates(router, ctx, params),
547
+ on_read_resource=lambda ctx, params: _on_read_resource(router, ctx, params),
548
+ on_list_prompts=lambda ctx, params: _on_list_prompts(router, ctx, params),
549
+ on_get_prompt=lambda ctx, params: _on_get_prompt(router, ctx, params),
550
+ on_completion=lambda ctx, params: _on_completion(router, ctx, params),
265
551
  )
552
+ # Set on the server rather than passed to `create_initialization_options`:
553
+ # the handshake rebuilds its capabilities from `server.extensions` once the
554
+ # protocol version is known, and ignores the ones computed here.
555
+ server.extensions = advertised_extensions(router)
266
556
  # Answer `initialize` immediately: the catalogue build runs as a background
267
557
  # task so one hung factory reads as a slow tools/list, not a failed
268
558
  # handshake (clients cap startup time).
if_cli/router/upstream.py CHANGED
@@ -193,14 +193,24 @@ class Upstream:
193
193
  except _BaseExceptionGroup as error:
194
194
  raise _leaf_exception(error) from error
195
195
 
196
- async def list_tools(self) -> list[dict[str, Any]]:
196
+ async def _list(self, fetch, attribute: str) -> list[dict[str, Any]]:
197
+ """Page through one list endpoint, dumping each item the way the router wants it.
198
+
199
+ Dicts rather than models: the merge rules in `catalog` and `content`
200
+ rewrite what the factory published (an `environment` enum spliced into a
201
+ schema, a variable appended to a URI template), and doing that to plain
202
+ JSON keeps every field the factory sent, including ones this `mcp_types`
203
+ version does not model. `server` validates the result on the way out.
204
+ """
205
+
197
206
  async def collect(client: Client) -> list[dict[str, Any]]:
198
207
  collected: list[dict[str, Any]] = []
199
208
  cursor: str | None = None
200
209
  while True:
201
- result = await client.list_tools(cursor=cursor)
210
+ result = await fetch(client, cursor)
202
211
  collected.extend(
203
- tool.model_dump(by_alias=True, mode="json", exclude_unset=True) for tool in result.tools
212
+ item.model_dump(by_alias=True, mode="json", exclude_unset=True)
213
+ for item in getattr(result, attribute)
204
214
  )
205
215
  if not result.next_cursor:
206
216
  return collected
@@ -208,6 +218,54 @@ class Upstream:
208
218
 
209
219
  return await self._run(collect)
210
220
 
221
+ async def list_tools(self) -> list[dict[str, Any]]:
222
+ return await self._list(lambda client, cursor: client.list_tools(cursor=cursor), "tools")
223
+
224
+ async def list_resources(self) -> list[dict[str, Any]]:
225
+ return await self._list(lambda client, cursor: client.list_resources(cursor=cursor), "resources")
226
+
227
+ async def list_resource_templates(self) -> list[dict[str, Any]]:
228
+ return await self._list(
229
+ lambda client, cursor: client.list_resource_templates(cursor=cursor), "resource_templates"
230
+ )
231
+
232
+ async def list_prompts(self) -> list[dict[str, Any]]:
233
+ return await self._list(lambda client, cursor: client.list_prompts(cursor=cursor), "prompts")
234
+
235
+ async def read_resource(self, uri: str) -> types.ReadResourceResult:
236
+ async def read(client: Client):
237
+ return await client.read_resource(uri)
238
+
239
+ return self._require_result(await self._run(read))
240
+
241
+ async def get_prompt(self, name: str, arguments: dict[str, str]) -> types.GetPromptResult:
242
+ async def get(client: Client):
243
+ return await client.get_prompt(name, arguments)
244
+
245
+ return self._require_result(await self._run(get))
246
+
247
+ async def complete(
248
+ self, ref: Any, argument: dict[str, str], context_arguments: dict[str, str] | None
249
+ ) -> types.CompleteResult:
250
+ async def complete(client: Client):
251
+ return await client.complete(ref, argument, context_arguments)
252
+
253
+ return self._require_result(await self._run(complete))
254
+
255
+ def _require_result(self, result: Any) -> Any:
256
+ """Turn a factory's request for input into an error, for the calls that cannot carry one.
257
+
258
+ `call_tool` answers an `InputRequiredResult` with a refusal that names the
259
+ connect page, because a tool result has somewhere to put it. A resource,
260
+ a prompt and a completion have no such shape: the protocol expects the
261
+ content or an error. So the same text is raised, and the caller reports
262
+ it as an error rather than inventing an empty body.
263
+ """
264
+ if isinstance(result, types.InputRequiredResult):
265
+ lines, url = _relayable_requests(result, self.profile["host"])
266
+ raise RuntimeError(self._connect_text(lines, url))
267
+ return result
268
+
211
269
  async def call_tool(self, name: str, arguments: dict[str, Any]) -> types.CallToolResult:
212
270
  async def call(client: Client):
213
271
  # `allow_input_required` hands back the factory's `InputRequiredResult`
@@ -261,12 +319,16 @@ class Upstream:
261
319
  from the factory, so it gives the caller somewhere to start without
262
320
  loosening what the origin check refuses to print.
263
321
  """
322
+ return types.CallToolResult(
323
+ content=[types.TextContent(text=self._connect_text(lines, url))],
324
+ is_error=True,
325
+ )
326
+
327
+ def _connect_text(self, lines: list[str], url: str | None) -> str:
328
+ """The factory's relayable messages plus the line naming where to connect."""
264
329
  connect = (
265
330
  f"[{self.code}] Connect at {url}, then retry."
266
331
  if url is not None
267
332
  else (f"[{self.code}] This factory needs input the router cannot relay. Start at {self.profile['host']}.")
268
333
  )
269
- return types.CallToolResult(
270
- content=[types.TextContent(text="\n".join([*lines, connect]))],
271
- is_error=True,
272
- )
334
+ return "\n".join([*lines, connect])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: insightfactory-cli
3
- Version: 1.1.2.dev28
3
+ Version: 1.1.2.dev29
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>
@@ -255,9 +255,10 @@ tst = example-tst
255
255
  writable = dev
256
256
  ```
257
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:
258
+ Every key except `writable`, `catalog` and `skills` is `<environment code> = <profile name>`;
259
+ file order is the environment order. `writable` is a comma-separated list of codes (default
260
+ none); `catalog` is one code (default the first environment); `skills` is `true` or `false`
261
+ (default false). Then:
261
262
 
262
263
  ```bash
263
264
  uv tool install 'insightfactory-cli[mcp]'
@@ -290,6 +291,7 @@ passed at all, replace the section's value outright, and `--allow-tool` is alway
290
291
  | `--read-only` | Forces every environment read-only, overriding both the section's `writable` and any `--writable`. Cannot be combined with `--writable`. |
291
292
  | `--catalog CODE` (default: the first environment) | Whose tool list is published; its schemas are the reference every environment is checked against. |
292
293
  | `--allow-tool NAME` (repeatable) | Extra tool names treated as read-only, beyond the shipped list. |
294
+ | `--skills` / `--no-skills` | Whether to advertise the experimental skills extension. Off unless the section says `skills = true` or `--skills` is passed. The two cannot be combined. |
293
295
  | `--timeout SECONDS` (default `120`) | Per upstream request; higher than the `api` command's default because some tools are slow. |
294
296
 
295
297
  `mcp`'s `--timeout` does not read `INSIGHTFACTORY_REQUEST_TIMEOUT`: it parses the flag with
@@ -304,6 +306,62 @@ Passing dev-only writes to production means changing an argument value, not
304
306
  connecting to a different server, so keep `--writable` scoped to the
305
307
  environments meant to be written by hand.
306
308
 
309
+ ### Resources, prompts and completion
310
+
311
+ Resources and prompts are not merged across environments the way tools are, because
312
+ a factory's non-tool content splits in two.
313
+
314
+ `skill://` bodies are documentation that belongs to a release, identical on every
315
+ environment running it, and they cross-reference each other by bare URI. So they are
316
+ served from the `--catalog` environment exactly as published. Rewriting those URIs to
317
+ carry an environment would leave every link inside them pointing at nothing.
318
+
319
+ A resource template is the other half: `if://task-config-schemas/{taskType}` resolves
320
+ to one factory's own activity catalogue, not to a shared document. The router
321
+ republishes each template with its own variable appended, so the client chooses the
322
+ environment when it expands the URI:
323
+
324
+ ```text
325
+ if://task-config-schemas/{taskType}{?environment}
326
+ ```
327
+
328
+ A template that already opens a query gets `{&environment}` instead, and one that
329
+ ends in a fragment gets the variable in front of it, because a `?` after a `#` is an
330
+ ordinary character rather than a query. A template that already declares an
331
+ `environment` variable is skipped with a line on stderr, so one nonconforming item
332
+ does not hide the rest of the list.
333
+
334
+ Reading a URI with no `?environment=` is answered by the catalogue environment,
335
+ unless the URI could be an expansion of a published template, which is refused
336
+ naming the codes to choose from. Membership of `resources/list` is deliberately not
337
+ the test: a skill body may link to a sibling document the factory never advertised,
338
+ and such a link carries no `environment` and could not be given one.
339
+ `completion/complete` reads the environment from the request's context arguments,
340
+ then from the reference URI, and falls back to the catalogue environment for anything
341
+ it does not recognise, because a half-typed context value is the normal state of a
342
+ completion request and completion should degrade rather than fail. The router answers
343
+ for the `environment` variable itself rather than asking a factory about an argument
344
+ it has never heard of.
345
+
346
+ Prompts gain a required `environment` argument like tools. The router also prepends a
347
+ line to every rendered prompt naming the environment chosen, because a factory writes
348
+ its prompts for a client talking to one factory and they tell the model to call tools
349
+ without one. Prepended rather than appended, and marked `[if-cli router]`: the
350
+ factory's own last turn may be an assistant prefill, which a trailing user turn would
351
+ silently end.
352
+
353
+ `--skills` only controls the advertisement. The handshake is answered before any
354
+ factory has been reached, so the router cannot ask the catalogue environment whether
355
+ it serves skills in time to repeat the claim, and an experimental capability is not
356
+ worth guessing at. The `skill://` resources are served either way.
357
+
358
+ The advertisement reaches only a client on MCP protocol 2026-07-28 or later.
359
+ `capabilities.extensions` was added in that revision, and the `initialize` handshake
360
+ every current client opens with negotiates a 2025 revision whose capability schema
361
+ has no such field, so the SDK strips it before it reaches the wire. Against
362
+ `foundryaz-dev` with `--skills` on, Claude Code sees no extension and still finds
363
+ every skill through `resources/list`, which is how clients discover them today.
364
+
307
365
  A tool the factory will not run until someone finishes a step in a browser, such
308
366
  as linking a personal Databricks identity, comes back as a failed call naming the
309
367
  page to visit rather than as a prompt. The router never relays the factory's
@@ -4,7 +4,7 @@ if_cli/cache.py,sha256=UfvHs3GQIVg0sVfKYWw4UGlq8d-8BEXuZIDfC1hwwjA,6587
4
4
  if_cli/callback_page.py,sha256=lTHd3-vzlT9ndAL7XYonBfy97lBW7apRw3V7SmhNObo,5907
5
5
  if_cli/cli.py,sha256=fF7HvsUgpKhkLFdXLMJ1JXHGjp3RwJt2YmPSEW0d8gY,6270
6
6
  if_cli/colour.py,sha256=2hbaM4ubTHGja6YZRD886eqnB7T2IaZyW5rvfOjxe4M,682
7
- if_cli/config.py,sha256=YFnegm_-e051bL0vyTFIkVrKamjgY08DVCqK0VgFNvA,10129
7
+ if_cli/config.py,sha256=QiphIhVLDN3sp39xMWKwXEyJWxx4IhXT28oEtmPc0ew,10601
8
8
  if_cli/constants.py,sha256=RW43KupAdhXfBsX6MxzZdJ-R57brX0q0TgIq8xXgvPs,269
9
9
  if_cli/http.py,sha256=W8HL3uZLeOu5MgmsOavM1Y6x4AcrInIQsAr5hx-j8AE,7671
10
10
  if_cli/main.py,sha256=pn-X2wrHNplc8ceT5_nEv5XlrTN3KgpK8UU4gAmBiWY,3212
@@ -17,18 +17,19 @@ if_cli/commands/api.py,sha256=XeVhhD39Oho4xNbMJIGh1I6vi53on5EGGsAtsb_5TAg,9016
17
17
  if_cli/commands/config.py,sha256=1KAxjme897NIbXTGBw1rNJlXnFvhbEUf5MAli8A93ns,1724
18
18
  if_cli/commands/login.py,sha256=vxsYcRZWsaNEzekyN1zy4ROJO-OCGUpjaVUBhtbK1LY,3255
19
19
  if_cli/commands/logout.py,sha256=TW0P4_tXcTtiHHR73xVPHyhlbOWD1jBnkZsMImCiCBw,668
20
- if_cli/commands/mcp.py,sha256=uU70j13n1WwkiyQigUTHPIIpc1B2PZO2O4wLIFDB87k,6586
20
+ if_cli/commands/mcp.py,sha256=SVF4egfA9RzjY5GHRlbk0mVi0HtmKfMh8v-SSYHjs98,7270
21
21
  if_cli/commands/profiles.py,sha256=tgZP5sMqzbFHXlfvglL2plsGdU0M3joo3wvQKX45J1U,3950
22
22
  if_cli/commands/set_token.py,sha256=BVyv_QmByvllr04pE2sz8JFEscHbIgOC-7ZPg61PRoc,1886
23
23
  if_cli/commands/token.py,sha256=CyiXkyH7vxgH91-n-XJzeHnLI6mZJwO9Js5AHvfHaeM,1120
24
24
  if_cli/router/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
25
  if_cli/router/catalog.py,sha256=Er2ZbWTwDUVU-qBGCvyA0bIlFk3UYw_6sVUlYwBhMpI,4992
26
+ if_cli/router/content.py,sha256=POL2n5NIY8yenQHJe8pUbTuTNpPSUSm0Y6Txbvehzhs,14487
26
27
  if_cli/router/log.py,sha256=YLWeHUb05HUnS3XHubCwy7oLZAUEGAiHcZysz_YMAAo,412
27
28
  if_cli/router/policy.py,sha256=CclbjuOA5mwwoQiNDa17iQeN9eXRbfy11bS9JFd8gL4,2840
28
- if_cli/router/server.py,sha256=RkS6Ddann-vAU0ZQAe2QaEB9dLBvEC33V9O-G4WSIy8,12116
29
- if_cli/router/upstream.py,sha256=dmwS7qSLnHiY7dEYpVENkfYIHs3DxkhhMB1SV66lE2Y,13104
30
- insightfactory_cli-1.1.2.dev28.dist-info/METADATA,sha256=lrvEqTY6PVwH8Y0TZJXWSJ2XGiDsDaYE9l7oMM3sk0w,20159
31
- insightfactory_cli-1.1.2.dev28.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
32
- insightfactory_cli-1.1.2.dev28.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
33
- insightfactory_cli-1.1.2.dev28.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
34
- insightfactory_cli-1.1.2.dev28.dist-info/RECORD,,
29
+ if_cli/router/server.py,sha256=JZ-7xbNyDadSY1VVLHpSVTjfx6vxI_uumxFaD22Pl0Y,26449
30
+ if_cli/router/upstream.py,sha256=2V78k6SXnOJ9Epc0tnhTuUPVBrYuW5vwflT-d3xKwVc,16122
31
+ insightfactory_cli-1.1.2.dev29.dist-info/METADATA,sha256=4M5zoiKKqT52vOoEOrx42CE_TsGyFawx1-4CLa7v5JE,23695
32
+ insightfactory_cli-1.1.2.dev29.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
33
+ insightfactory_cli-1.1.2.dev29.dist-info/entry_points.txt,sha256=UlX854D7f-7dBj5F0wGdakVy6vopvP9hA1BN-gv739w,44
34
+ insightfactory_cli-1.1.2.dev29.dist-info/licenses/LICENSE,sha256=8eZ1YAABL398qESVJc_FlK1voYQ0GAh-R_rNBQK6QH8,234
35
+ insightfactory_cli-1.1.2.dev29.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.3
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any