toolpipe 0.1.0__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.
- toolpipe/__init__.py +3 -0
- toolpipe/__main__.py +4 -0
- toolpipe/cli.py +258 -0
- toolpipe/config.py +163 -0
- toolpipe/constants.py +11 -0
- toolpipe/discovery.py +277 -0
- toolpipe/errors.py +33 -0
- toolpipe/models.py +103 -0
- toolpipe/results/__init__.py +0 -0
- toolpipe/results/cleanup.py +33 -0
- toolpipe/results/codec.py +76 -0
- toolpipe/results/manager.py +168 -0
- toolpipe/results/preview.py +108 -0
- toolpipe/results/search.py +121 -0
- toolpipe/results/selector.py +22 -0
- toolpipe/results/store.py +324 -0
- toolpipe/server/__init__.py +0 -0
- toolpipe/server/app.py +121 -0
- toolpipe/server/approval.py +91 -0
- toolpipe/server/consent.py +256 -0
- toolpipe/server/dispatcher.py +28 -0
- toolpipe/server/registry.py +213 -0
- toolpipe/server/schema_transform.py +54 -0
- toolpipe/server/virtualization.py +154 -0
- toolpipe/tools/__init__.py +22 -0
- toolpipe/tools/_common.py +26 -0
- toolpipe/tools/inspect.py +32 -0
- toolpipe/tools/pipe.py +142 -0
- toolpipe/tools/policy.py +65 -0
- toolpipe/tools/read.py +37 -0
- toolpipe/tools/release.py +23 -0
- toolpipe/tools/search.py +27 -0
- toolpipe/tools/select.py +39 -0
- toolpipe/tools/servers.py +121 -0
- toolpipe/utils/__init__.py +0 -0
- toolpipe/utils/atomic.py +26 -0
- toolpipe/utils/sizes.py +11 -0
- toolpipe/utils/time.py +25 -0
- toolpipe-0.1.0.dist-info/METADATA +251 -0
- toolpipe-0.1.0.dist-info/RECORD +43 -0
- toolpipe-0.1.0.dist-info/WHEEL +4 -0
- toolpipe-0.1.0.dist-info/entry_points.txt +2 -0
- toolpipe-0.1.0.dist-info/licenses/LICENSE +192 -0
toolpipe/__init__.py
ADDED
toolpipe/__main__.py
ADDED
toolpipe/cli.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""ToolPipe CLI: serve + local result debugging."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from toolpipe.config import load_config
|
|
13
|
+
from toolpipe.errors import ConfigurationError, ToolPipeError
|
|
14
|
+
from toolpipe.models import ToolPipeConfig
|
|
15
|
+
from toolpipe.results.cleanup import periodic_cleanup, run_startup_cleanup
|
|
16
|
+
from toolpipe.results.manager import ResultManager
|
|
17
|
+
from toolpipe.server.app import create_app
|
|
18
|
+
from toolpipe.utils.sizes import format_bytes
|
|
19
|
+
from toolpipe.utils.time import parse_iso, utc_now
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
p = argparse.ArgumentParser(
|
|
24
|
+
prog="toolpipe", description="Pass MCP results by reference, not through the LLM."
|
|
25
|
+
)
|
|
26
|
+
sub = p.add_subparsers(dest="command")
|
|
27
|
+
serve = sub.add_parser("serve", help="Run ToolPipe MCP server (STDIO).")
|
|
28
|
+
serve.add_argument("--config", default=None)
|
|
29
|
+
serve.add_argument("--log-level", default="WARNING")
|
|
30
|
+
serve.add_argument("--import-mcp-json", default=None)
|
|
31
|
+
serve.add_argument("--import-codex-toml", default=None)
|
|
32
|
+
serve.add_argument("--no-discover", action="store_true")
|
|
33
|
+
serve.add_argument("--no-dynamic-servers", action="store_true")
|
|
34
|
+
|
|
35
|
+
inspect_p = sub.add_parser("inspect", help="Show a stored result (local debugging).")
|
|
36
|
+
inspect_p.add_argument("ref")
|
|
37
|
+
inspect_p.add_argument("--config", default="toolpipe.toml")
|
|
38
|
+
|
|
39
|
+
results_p = sub.add_parser("results", help="List stored results.")
|
|
40
|
+
results_p.add_argument("--config", default="toolpipe.toml")
|
|
41
|
+
|
|
42
|
+
stats_p = sub.add_parser("stats", help="Show result store metrics.")
|
|
43
|
+
stats_p.add_argument("--config", default="toolpipe.toml")
|
|
44
|
+
|
|
45
|
+
clean_p = sub.add_parser("clean", help="Remove expired results.")
|
|
46
|
+
clean_p.add_argument("--config", default="toolpipe.toml")
|
|
47
|
+
return p
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _configure_logging(level: str) -> None:
|
|
51
|
+
logging.basicConfig(stream=sys.stderr, level=getattr(logging, level.upper(), logging.WARNING))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _open_manager(config_path: str) -> tuple[ToolPipeConfig, ResultManager]:
|
|
55
|
+
try:
|
|
56
|
+
config = load_config(config_path)
|
|
57
|
+
except ConfigurationError as e:
|
|
58
|
+
print(f"toolpipe: configuration error: {e}", file=sys.stderr)
|
|
59
|
+
raise SystemExit(2)
|
|
60
|
+
return config, ResultManager.from_storage_dir(config.results.storage_dir, config.results)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_serve_config(args: argparse.Namespace) -> tuple[ToolPipeConfig, list]:
|
|
64
|
+
"""Build the serve config.
|
|
65
|
+
|
|
66
|
+
Returns (config, discovered): TOML + explicit imports are pre-approved;
|
|
67
|
+
auto-discovered entries stay pending until first-use consent.
|
|
68
|
+
"""
|
|
69
|
+
from toolpipe import discovery
|
|
70
|
+
from toolpipe.config import merge_server_maps, servers_from_discovered
|
|
71
|
+
from toolpipe.models import ServerConfig
|
|
72
|
+
|
|
73
|
+
if args.config is not None:
|
|
74
|
+
base = load_config(args.config)
|
|
75
|
+
elif Path("toolpipe.toml").exists():
|
|
76
|
+
base = load_config("toolpipe.toml")
|
|
77
|
+
else:
|
|
78
|
+
base = ToolPipeConfig()
|
|
79
|
+
|
|
80
|
+
imported: dict = {}
|
|
81
|
+
if args.import_mcp_json is not None:
|
|
82
|
+
try:
|
|
83
|
+
text = Path(args.import_mcp_json).read_text()
|
|
84
|
+
except OSError as e:
|
|
85
|
+
raise ConfigurationError(f"Cannot read {args.import_mcp_json}: {e}")
|
|
86
|
+
try:
|
|
87
|
+
entries = discovery.parse_mcp_json(text, args.import_mcp_json)
|
|
88
|
+
except ValueError as e:
|
|
89
|
+
raise ConfigurationError(f"Invalid MCP JSON {args.import_mcp_json}: {e}")
|
|
90
|
+
imported.update(servers_from_discovered(entries, strict=True, source_label="import"))
|
|
91
|
+
if args.import_codex_toml is not None:
|
|
92
|
+
try:
|
|
93
|
+
text = Path(args.import_codex_toml).read_text()
|
|
94
|
+
except OSError as e:
|
|
95
|
+
raise ConfigurationError(f"Cannot read {args.import_codex_toml}: {e}")
|
|
96
|
+
try:
|
|
97
|
+
entries = discovery.parse_codex_toml(text, args.import_codex_toml)
|
|
98
|
+
except ValueError as e:
|
|
99
|
+
raise ConfigurationError(f"Invalid Codex TOML {args.import_codex_toml}: {e}")
|
|
100
|
+
imported.update(servers_from_discovered(entries, strict=True, source_label="import"))
|
|
101
|
+
|
|
102
|
+
discovered: dict = {}
|
|
103
|
+
if not args.no_discover:
|
|
104
|
+
discovered = servers_from_discovered(
|
|
105
|
+
discovery.discover("."), strict=False, source_label="discovery"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Precedence: TOML < explicit imports. Discovered entries are returned
|
|
109
|
+
# separately as pending (consent on first use), not merged as active.
|
|
110
|
+
# Persisted scope files layer around this chain (see ConsentStore).
|
|
111
|
+
servers = merge_server_maps(base.servers, imported)
|
|
112
|
+
pending = [
|
|
113
|
+
ServerConfig(name=name, command=cfg.command, args=cfg.args, url=cfg.url, env=cfg.env)
|
|
114
|
+
for name, cfg in discovered.items()
|
|
115
|
+
if name not in servers
|
|
116
|
+
]
|
|
117
|
+
return (
|
|
118
|
+
ToolPipeConfig(name=base.name, results=base.results, servers=servers),
|
|
119
|
+
pending,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _rel(iso: str, now: datetime) -> str:
|
|
124
|
+
seconds = max(0, int((now - parse_iso(iso)).total_seconds()))
|
|
125
|
+
return _fmt_duration(seconds)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _rel_remaining(iso: str, now: datetime) -> str:
|
|
129
|
+
seconds = max(0, int((parse_iso(iso) - now).total_seconds()))
|
|
130
|
+
return _fmt_duration(seconds)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _fmt_duration(seconds: int) -> str:
|
|
134
|
+
if seconds < 60:
|
|
135
|
+
return f"{seconds}s"
|
|
136
|
+
if seconds < 3600:
|
|
137
|
+
return f"{seconds // 60}m"
|
|
138
|
+
if seconds < 86400:
|
|
139
|
+
return f"{seconds // 3600}h"
|
|
140
|
+
return f"{seconds // 86400}d"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _serve(args: argparse.Namespace) -> None:
|
|
144
|
+
_configure_logging(args.log_level)
|
|
145
|
+
try:
|
|
146
|
+
config, discovered = _load_serve_config(args)
|
|
147
|
+
|
|
148
|
+
async def _run() -> None:
|
|
149
|
+
app = await create_app(
|
|
150
|
+
config, discovered=discovered, dynamic_servers=not args.no_dynamic_servers
|
|
151
|
+
)
|
|
152
|
+
if not config.servers:
|
|
153
|
+
print(
|
|
154
|
+
"toolpipe: no downstream servers configured — "
|
|
155
|
+
"only control tools are exposed. Add servers via "
|
|
156
|
+
"toolpipe_add_server, --config, or --import-mcp-json.",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
run_startup_cleanup(app.manager)
|
|
160
|
+
cleanup_task = asyncio.create_task(periodic_cleanup(app.manager))
|
|
161
|
+
try:
|
|
162
|
+
# STDIO is the protocol channel; logs go to stderr only.
|
|
163
|
+
await app.mcp.run_stdio_async()
|
|
164
|
+
finally:
|
|
165
|
+
cleanup_task.cancel()
|
|
166
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
167
|
+
await cleanup_task
|
|
168
|
+
app.close()
|
|
169
|
+
|
|
170
|
+
asyncio.run(_run())
|
|
171
|
+
except ConfigurationError as e:
|
|
172
|
+
print(f"toolpipe: configuration error: {e}", file=sys.stderr)
|
|
173
|
+
raise SystemExit(2)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _inspect(config_path: str, ref: str) -> None:
|
|
177
|
+
_, manager = _open_manager(config_path)
|
|
178
|
+
try:
|
|
179
|
+
info = manager.inspect(ref)
|
|
180
|
+
except ToolPipeError as e:
|
|
181
|
+
print(f"toolpipe: {e}", file=sys.stderr)
|
|
182
|
+
raise SystemExit(1)
|
|
183
|
+
finally:
|
|
184
|
+
manager.close()
|
|
185
|
+
print(
|
|
186
|
+
json.dumps(
|
|
187
|
+
{
|
|
188
|
+
"ref": info.ref,
|
|
189
|
+
"content_type": info.content_type,
|
|
190
|
+
"size_bytes": info.size_bytes,
|
|
191
|
+
"source_tool": info.source_tool,
|
|
192
|
+
"created_at": info.created_at,
|
|
193
|
+
"expires_at": info.expires_at,
|
|
194
|
+
"preview": json.loads(info.preview_json) if info.preview_json else None,
|
|
195
|
+
},
|
|
196
|
+
indent=2,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _results(config_path: str) -> None:
|
|
202
|
+
_, manager = _open_manager(config_path)
|
|
203
|
+
try:
|
|
204
|
+
rows = manager.list_results()
|
|
205
|
+
finally:
|
|
206
|
+
manager.close()
|
|
207
|
+
now = utc_now()
|
|
208
|
+
print(f"{'REF':<24} {'TYPE':<18} {'SIZE':<10} {'AGE':<6} {'EXPIRES'}")
|
|
209
|
+
for row in rows:
|
|
210
|
+
print(
|
|
211
|
+
f"{row.ref:<24} {row.content_type:<18} {format_bytes(row.size_bytes):<10} "
|
|
212
|
+
f"{_rel(row.created_at, now):<6} {_rel_remaining(row.expires_at, now)}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _stats(config_path: str) -> None:
|
|
217
|
+
_, manager = _open_manager(config_path)
|
|
218
|
+
try:
|
|
219
|
+
stats = manager.stats()
|
|
220
|
+
finally:
|
|
221
|
+
manager.close()
|
|
222
|
+
print(f"Proxied tool calls: {stats['proxied_tool_calls']}")
|
|
223
|
+
print(f"Virtualized results: {stats['virtualized_results']}")
|
|
224
|
+
print(f"Bytes virtualized: {format_bytes(stats['virtualized_bytes'])}")
|
|
225
|
+
print(f"Bytes returned as refs: {format_bytes(stats['reference_response_bytes'])}")
|
|
226
|
+
print(f"Estimated bytes avoided: {format_bytes(stats['estimated_bytes_avoided'])}")
|
|
227
|
+
print(f"Pipe calls: {stats['pipe_calls']}")
|
|
228
|
+
print(f"Stored results: {stats['stored_results']}")
|
|
229
|
+
print(f"Current storage: {format_bytes(stats['current_storage_bytes'])}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _clean(config_path: str) -> None:
|
|
233
|
+
_, manager = _open_manager(config_path)
|
|
234
|
+
try:
|
|
235
|
+
count = manager.cleanup_expired()
|
|
236
|
+
finally:
|
|
237
|
+
manager.close()
|
|
238
|
+
print(f"Removed {count} expired result(s).")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main(argv: list[str] | None = None) -> None:
|
|
242
|
+
args = build_parser().parse_args(argv)
|
|
243
|
+
if args.command is None:
|
|
244
|
+
build_parser().print_help()
|
|
245
|
+
elif args.command == "serve":
|
|
246
|
+
_serve(args)
|
|
247
|
+
elif args.command == "inspect":
|
|
248
|
+
_inspect(args.config, args.ref)
|
|
249
|
+
elif args.command == "results":
|
|
250
|
+
_results(args.config)
|
|
251
|
+
elif args.command == "stats":
|
|
252
|
+
_stats(args.config)
|
|
253
|
+
elif args.command == "clean":
|
|
254
|
+
_clean(args.config)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
main()
|
toolpipe/config.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""TOML configuration loading with ${VAR} environment substitution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from toolpipe.errors import ConfigurationError
|
|
13
|
+
from toolpipe.models import ResultSettings, ServerConfig, ToolPipeConfig
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from toolpipe.discovery import DiscoveredServer
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _substitute_env(value: str) -> str:
|
|
24
|
+
def repl(match: re.Match[str]) -> str:
|
|
25
|
+
var = match.group(1)
|
|
26
|
+
if var not in os.environ:
|
|
27
|
+
raise ConfigurationError(f"Environment variable {var} is not set.")
|
|
28
|
+
return os.environ[var]
|
|
29
|
+
|
|
30
|
+
return _ENV_PATTERN.sub(repl, value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_env_vars(env: dict[str, str]) -> dict[str, str]:
|
|
34
|
+
"""Resolve ${VAR} references now (fail fast on missing variables)."""
|
|
35
|
+
return {k: _substitute_env(v) for k, v in env.items()}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _substitute_deep(value: object) -> object:
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
return _substitute_env(value)
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return {k: _substitute_deep(v) for k, v in value.items()}
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
return [_substitute_deep(v) for v in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config(path: str | Path) -> ToolPipeConfig:
|
|
49
|
+
"""Load and validate a toolpipe.toml file."""
|
|
50
|
+
path = Path(path)
|
|
51
|
+
try:
|
|
52
|
+
raw = tomllib.loads(path.read_text())
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
raise ConfigurationError(f"Config file not found: {path}")
|
|
55
|
+
except tomllib.TOMLDecodeError as e:
|
|
56
|
+
raise ConfigurationError(f"Invalid TOML in {path}: {e}")
|
|
57
|
+
|
|
58
|
+
raw = _substitute_deep(raw)
|
|
59
|
+
assert isinstance(raw, dict)
|
|
60
|
+
|
|
61
|
+
toolpipe_raw = raw.get("toolpipe", {})
|
|
62
|
+
if not isinstance(toolpipe_raw, dict):
|
|
63
|
+
raise ConfigurationError("[toolpipe] must be a table.")
|
|
64
|
+
name = toolpipe_raw.get("name", "ToolPipe")
|
|
65
|
+
if not isinstance(name, str) or not name:
|
|
66
|
+
raise ConfigurationError("[toolpipe].name must be a non-empty string.")
|
|
67
|
+
|
|
68
|
+
results_raw = raw.get("results", {})
|
|
69
|
+
if not isinstance(results_raw, dict):
|
|
70
|
+
raise ConfigurationError("[results] must be a table.")
|
|
71
|
+
unknown = set(results_raw) - set(ResultSettings.__dataclass_fields__)
|
|
72
|
+
if unknown:
|
|
73
|
+
raise ConfigurationError(f"Unknown [results] settings: {sorted(unknown)}.")
|
|
74
|
+
try:
|
|
75
|
+
results = ResultSettings(**results_raw)
|
|
76
|
+
except TypeError as e:
|
|
77
|
+
raise ConfigurationError(f"Invalid [results] setting: {e}")
|
|
78
|
+
for field in (
|
|
79
|
+
"inline_max_bytes",
|
|
80
|
+
"preview_max_bytes",
|
|
81
|
+
"read_max_bytes",
|
|
82
|
+
"max_result_bytes",
|
|
83
|
+
"max_store_bytes",
|
|
84
|
+
"ttl_seconds",
|
|
85
|
+
):
|
|
86
|
+
if getattr(results, field) < 0:
|
|
87
|
+
raise ConfigurationError(f"[results].{field} must be >= 0.")
|
|
88
|
+
if not results.storage_dir:
|
|
89
|
+
raise ConfigurationError("[results].storage_dir must be a non-empty string.")
|
|
90
|
+
|
|
91
|
+
servers_raw = raw.get("servers", {})
|
|
92
|
+
if not isinstance(servers_raw, dict):
|
|
93
|
+
raise ConfigurationError("[servers] must be a table.")
|
|
94
|
+
servers: dict[str, ServerConfig] = {}
|
|
95
|
+
for server_name, spec in servers_raw.items():
|
|
96
|
+
if not isinstance(spec, dict):
|
|
97
|
+
raise ConfigurationError(f"[servers.{server_name}] must be a table.")
|
|
98
|
+
command = spec.get("command")
|
|
99
|
+
url = spec.get("url")
|
|
100
|
+
if command is None and url is None:
|
|
101
|
+
raise ConfigurationError(f"[servers.{server_name}] needs either 'command' or 'url'.")
|
|
102
|
+
if command is not None and url is not None:
|
|
103
|
+
raise ConfigurationError(
|
|
104
|
+
f"[servers.{server_name}] cannot set both 'command' and 'url'."
|
|
105
|
+
)
|
|
106
|
+
if command is not None and not isinstance(command, str):
|
|
107
|
+
raise ConfigurationError(f"[servers.{server_name}.command] must be a string.")
|
|
108
|
+
if url is not None and not isinstance(url, str):
|
|
109
|
+
raise ConfigurationError(f"[servers.{server_name}.url] must be a string.")
|
|
110
|
+
args = spec.get("args", [])
|
|
111
|
+
env = spec.get("env", {})
|
|
112
|
+
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
|
|
113
|
+
raise ConfigurationError(f"[servers.{server_name}.args] must be a list of strings.")
|
|
114
|
+
if not isinstance(env, dict) or not all(
|
|
115
|
+
isinstance(k, str) and isinstance(v, str) for k, v in env.items()
|
|
116
|
+
):
|
|
117
|
+
raise ConfigurationError(f"[servers.{server_name}.env] must be string key/values.")
|
|
118
|
+
servers[server_name] = ServerConfig(
|
|
119
|
+
name=server_name, command=command, args=args, url=url, env=dict(env)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return ToolPipeConfig(name=name, results=results, servers=servers)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def servers_from_discovered(
|
|
126
|
+
entries: list[DiscoveredServer], *, strict: bool, source_label: str = "discovery"
|
|
127
|
+
) -> dict[str, ServerConfig]:
|
|
128
|
+
"""Convert discovery entries to ServerConfigs.
|
|
129
|
+
|
|
130
|
+
Applies ${VAR} substitution. Strict mode (explicit --import-*) raises on
|
|
131
|
+
bad entries; auto-discovery skips them with a warning instead.
|
|
132
|
+
"""
|
|
133
|
+
servers: dict[str, ServerConfig] = {}
|
|
134
|
+
for entry in entries:
|
|
135
|
+
try:
|
|
136
|
+
env = {k: _substitute_env(v) for k, v in entry.env.items()}
|
|
137
|
+
except ConfigurationError as e:
|
|
138
|
+
if strict:
|
|
139
|
+
raise
|
|
140
|
+
logger.warning("%s skipped server '%s': %s", source_label, entry.name, e)
|
|
141
|
+
continue
|
|
142
|
+
if entry.command is None and entry.url is None:
|
|
143
|
+
err = ConfigurationError(f"Server '{entry.name}' needs 'command' or 'url'.")
|
|
144
|
+
if strict:
|
|
145
|
+
raise err
|
|
146
|
+
logger.warning("%s skipped server '%s': %s", source_label, entry.name, err)
|
|
147
|
+
continue
|
|
148
|
+
servers[entry.name] = ServerConfig(
|
|
149
|
+
name=entry.name,
|
|
150
|
+
command=entry.command,
|
|
151
|
+
args=list(entry.args),
|
|
152
|
+
url=entry.url,
|
|
153
|
+
env=env,
|
|
154
|
+
)
|
|
155
|
+
return servers
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def merge_server_maps(*maps: dict[str, ServerConfig]) -> dict[str, ServerConfig]:
|
|
159
|
+
"""Merge server maps; later maps win on name collisions."""
|
|
160
|
+
merged: dict[str, ServerConfig] = {}
|
|
161
|
+
for mapping in maps:
|
|
162
|
+
merged.update(mapping)
|
|
163
|
+
return merged
|
toolpipe/constants.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shared constants."""
|
|
2
|
+
|
|
3
|
+
CONTROL_TAG = "toolpipe-control"
|
|
4
|
+
RESERVED_NAMESPACE = "toolpipe"
|
|
5
|
+
REF_PREFIX = "res_"
|
|
6
|
+
|
|
7
|
+
INSTRUCTIONS = (
|
|
8
|
+
"Large downstream results may be returned as ToolPipe result references. "
|
|
9
|
+
"Use toolpipe_inspect_result, toolpipe_select_result, or "
|
|
10
|
+
"toolpipe_pipe_result instead of requesting the full result."
|
|
11
|
+
)
|