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 +17 -2
- if_cli/config.py +16 -2
- if_cli/router/content.py +298 -0
- if_cli/router/server.py +297 -7
- if_cli/router/upstream.py +69 -7
- {insightfactory_cli-1.1.2.dev28.dist-info → insightfactory_cli-1.1.2.dev29.dist-info}/METADATA +62 -4
- {insightfactory_cli-1.1.2.dev28.dist-info → insightfactory_cli-1.1.2.dev29.dist-info}/RECORD +10 -9
- {insightfactory_cli-1.1.2.dev28.dist-info → insightfactory_cli-1.1.2.dev29.dist-info}/WHEEL +1 -1
- {insightfactory_cli-1.1.2.dev28.dist-info → insightfactory_cli-1.1.2.dev29.dist-info}/entry_points.txt +0 -0
- {insightfactory_cli-1.1.2.dev28.dist-info → insightfactory_cli-1.1.2.dev29.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
-
|
|
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:
|
if_cli/router/content.py
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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(
|
|
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
|
|
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
|
|
210
|
+
result = await fetch(client, cursor)
|
|
202
211
|
collected.extend(
|
|
203
|
-
|
|
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
|
|
270
|
-
content=[types.TextContent(text="\n".join([*lines, connect]))],
|
|
271
|
-
is_error=True,
|
|
272
|
-
)
|
|
334
|
+
return "\n".join([*lines, connect])
|
{insightfactory_cli-1.1.2.dev28.dist-info → insightfactory_cli-1.1.2.dev29.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: insightfactory-cli
|
|
3
|
-
Version: 1.1.2.
|
|
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 `
|
|
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)
|
|
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
|
{insightfactory_cli-1.1.2.dev28.dist-info → insightfactory_cli-1.1.2.dev29.dist-info}/RECORD
RENAMED
|
@@ -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=
|
|
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=
|
|
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=
|
|
29
|
-
if_cli/router/upstream.py,sha256=
|
|
30
|
-
insightfactory_cli-1.1.2.
|
|
31
|
-
insightfactory_cli-1.1.2.
|
|
32
|
-
insightfactory_cli-1.1.2.
|
|
33
|
-
insightfactory_cli-1.1.2.
|
|
34
|
-
insightfactory_cli-1.1.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|