seren-workbench 0.2.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.
Files changed (52) hide show
  1. seren_workbench/__init__.py +32 -0
  2. seren_workbench/__main__.py +56 -0
  3. seren_workbench/_version.py +24 -0
  4. seren_workbench/app.py +170 -0
  5. seren_workbench/config.py +156 -0
  6. seren_workbench/dynamic_tools/__init__.py +10 -0
  7. seren_workbench/dynamic_tools/dynamic_tool_registry.py +139 -0
  8. seren_workbench/dynamic_tools/manifest_loader.py +385 -0
  9. seren_workbench/dynamic_tools/manifest_models.py +100 -0
  10. seren_workbench/dynamic_tools/param_subst.py +110 -0
  11. seren_workbench/dynamic_tools/process_dispatcher.py +116 -0
  12. seren_workbench/dynamic_tools/tool_audit_log.py +60 -0
  13. seren_workbench/dynamic_tools/web_dispatcher.py +122 -0
  14. seren_workbench/dynamic_tools/yaml_dispatched_tool.py +282 -0
  15. seren_workbench/mcp/__init__.py +8 -0
  16. seren_workbench/mcp/server.py +412 -0
  17. seren_workbench/models/__init__.py +4 -0
  18. seren_workbench/models/tools/__init__.py +0 -0
  19. seren_workbench/models/tools/cluster_tools.py +101 -0
  20. seren_workbench/models/tools/consolidator_tools.py +104 -0
  21. seren_workbench/models/tools/ensure_service_running_tool.py +151 -0
  22. seren_workbench/models/tools/introspection_and_agency_tools.py +177 -0
  23. seren_workbench/models/tools/log_tools.py +113 -0
  24. seren_workbench/models/tools/memory_tools.py +164 -0
  25. seren_workbench/models/tools/model_tools.py +89 -0
  26. seren_workbench/models/tools/scheduler_tools.py +178 -0
  27. seren_workbench/models/tools/self_tools.py +161 -0
  28. seren_workbench/models/tools/service_control_tools.py +138 -0
  29. seren_workbench/models/tools/time_tools.py +49 -0
  30. seren_workbench/models/tools/wait_for_service_tool.py +110 -0
  31. seren_workbench/models/tools/web_tools.py +235 -0
  32. seren_workbench/models/tools/which_model_tool.py +73 -0
  33. seren_workbench/routes/__init__.py +7 -0
  34. seren_workbench/routes/config.py +42 -0
  35. seren_workbench/routes/info.py +35 -0
  36. seren_workbench/routes/logs.py +32 -0
  37. seren_workbench/routes/tools.py +78 -0
  38. seren_workbench/tool_config/__init__.py +12 -0
  39. seren_workbench/tool_config/mcp_config.py +94 -0
  40. seren_workbench/tool_config/tool_section.py +75 -0
  41. seren_workbench/tool_registry.py +300 -0
  42. seren_workbench/viewer/ui/body.html +79 -0
  43. seren_workbench/viewer/ui/header_aside.html +2 -0
  44. seren_workbench/viewer/ui/scripts.js +345 -0
  45. seren_workbench/viewer/ui/styles.css +326 -0
  46. seren_workbench/viewer/ui/tabs.html +4 -0
  47. seren_workbench-0.2.0.dist-info/METADATA +22 -0
  48. seren_workbench-0.2.0.dist-info/RECORD +52 -0
  49. seren_workbench-0.2.0.dist-info/WHEEL +5 -0
  50. seren_workbench-0.2.0.dist-info/entry_points.txt +2 -0
  51. seren_workbench-0.2.0.dist-info/licenses/LICENSE +674 -0
  52. seren_workbench-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,32 @@
1
+ """
2
+ seren_workbench — the MCP server for the Seren stack.
3
+
4
+ The tool surface that LLMs see. Builtin tools (memory, web, time, cluster, etc.)
5
+ are defined in models/tools/; dynamic tools are loaded from YAML manifests in a
6
+ tools/ directory. This package wires everything into a FastAPI app that serves
7
+ the MCP transport AND an operator dashboard.
8
+
9
+ Integrates seren_meninges (config/auth/viewer baseplate) and seren_sinew
10
+ (request logging) following the same pattern as seren_loci, seren_memory,
11
+ seren_corpus_callosum, and seren_probe.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ # Version flows from the git tag via setuptools-scm (written to _version.py at
16
+ # build time, read here). Fallback only fires in a bare source checkout that was
17
+ # never built. Mirrors the family so every seren_* exposes __version__ alike.
18
+ try:
19
+ from ._version import version as __version__
20
+ except Exception: # noqa: BLE001 - source checkout without a build
21
+ __version__ = "0.0.0+unknown"
22
+
23
+ from .config import WorkbenchConfig, load_config # noqa: F401,E402
24
+ from .tool_registry import ToolRegistry, build_registry # noqa: F401,E402
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ "WorkbenchConfig",
29
+ "load_config",
30
+ "ToolRegistry",
31
+ "build_registry",
32
+ ]
@@ -0,0 +1,56 @@
1
+ """
2
+ seren_workbench.__main__
3
+ ════════════════════════════════════════════════════════════════════════
4
+
5
+ Entry point for ``python -m seren_workbench`` — starts the uvicorn server.
6
+
7
+ Usage::
8
+
9
+ python -m seren_workbench [--config CONFIG] [--port PORT] [--host HOST]
10
+
11
+ Config is loaded from ./seren-workbench.yaml by default; override with
12
+ --config or the SEREN_WORKBENCH_CONFIG env var. The resolved path is
13
+ threaded through cfg.source_path so the tools block (McpConfig) loads
14
+ from the SAME file — including when --config is used.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import os
20
+ import sys
21
+
22
+
23
+ def main():
24
+ parser = argparse.ArgumentParser(
25
+ description="SerenWorkbench — MCP server for the Seren stack")
26
+ parser.add_argument("--config", "-c", default=None,
27
+ help="Path to seren-workbench.yaml (default: "
28
+ "./seren-workbench.yaml or $SEREN_WORKBENCH_CONFIG)")
29
+ parser.add_argument("--port", type=int, default=0,
30
+ help="Override the configured port")
31
+ parser.add_argument("--host", type=str, default=None,
32
+ help="Override the configured host")
33
+ args = parser.parse_args()
34
+
35
+ from .app import create_app
36
+ from .config import load_config
37
+
38
+ cfg = load_config(args.config)
39
+ if args.port:
40
+ cfg.server.port = args.port
41
+ if args.host:
42
+ cfg.server.host = args.host
43
+
44
+ app = create_app(cfg)
45
+
46
+ import uvicorn
47
+ uvicorn.run(
48
+ app,
49
+ host=cfg.server.host,
50
+ port=cfg.server.port,
51
+ log_level=os.environ.get("SEREN_WORKBENCH_LOG_LEVEL", "info").lower(),
52
+ )
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = 'g42a570fea'
seren_workbench/app.py ADDED
@@ -0,0 +1,170 @@
1
+ """
2
+ seren_workbench.app
3
+ ════════════════════════════════════════════════════════════════════════
4
+
5
+ The FastAPI application for the Seren Workbench MCP server. Wires the
6
+ builtin tools, dynamic tool registry, optional bearer auth, the operator
7
+ dashboard, and the MCP transport for LLMs to connect to.
8
+
9
+ Serves:
10
+ GET / — service info + tool counts
11
+ GET /health — liveness
12
+ GET /tools — JSON list of all registered tools (for the LLM)
13
+ GET /viewer — the operator dashboard HTML
14
+ POST /tools/state — enable/disable a tool or action (viewer toggles)
15
+ GET /tools/state — current enable/disable snapshot
16
+ GET /config — server config JSON
17
+ GET /logs — audit log entries
18
+ /mcp — the MCP transport endpoint
19
+
20
+ Integrates seren_meninges (config/auth/viewer baseplate) and seren_sinew
21
+ (request logging) — following the same pattern as the rest of the Seren family.
22
+
23
+ DEPENDENCY INJECTION: the lifespan builds one httpx.AsyncClient per Seren
24
+ service (base URLs from cfg.services) and registers them BY PARAMETER NAME
25
+ in app.state.di_registry. The MCP layer injects them into builtin tool
26
+ impls whose params are annotated httpx.AsyncClient / McpConfig. Without
27
+ this the impls' DI defaults are None and every service call explodes —
28
+ the half-cutover state this port started in.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import time
33
+ from contextlib import asynccontextmanager, AsyncExitStack
34
+ from pathlib import Path
35
+ from typing import Optional
36
+
37
+ import httpx
38
+ from fastapi import FastAPI, Request
39
+ from fastapi.responses import HTMLResponse
40
+
41
+ from .config import WorkbenchConfig, load_config
42
+ from .tool_registry import build_registry
43
+ from .routes import info as info_routes
44
+ from .routes import tools as tools_routes
45
+ from .routes import config as config_routes
46
+ from .routes import logs as logs_routes
47
+
48
+ from seren_meninges import get_version
49
+ from seren_meninges.auth import bearer_auth_middleware
50
+ from seren_meninges.viewer import render_from_dir
51
+ from seren_sinew.request_log import RequestLoggingMiddleware
52
+
53
+ from . import __version__ as _fallback_version
54
+ APP_VERSION = get_version("seren-workbench", fallback=_fallback_version)
55
+
56
+
57
+ def create_app(config: Optional[WorkbenchConfig] = None) -> FastAPI:
58
+ cfg = config or load_config()
59
+ bearer = cfg.server.resolve_bearer()
60
+
61
+ @asynccontextmanager
62
+ async def lifespan(app: FastAPI):
63
+ app.state.config = cfg
64
+
65
+ # Load McpConfig (tool-level knobs) from the SAME yaml load_config
66
+ # resolved — no CWD-vs-argv[0] split brain between the server block
67
+ # and the tools block.
68
+ from .tool_config.mcp_config import McpConfig as _McpConfig
69
+ mcp_config = _McpConfig.load(cfg.source_path)
70
+ app.state.mcp_config = mcp_config
71
+
72
+ app.state.tool_registry = build_registry(
73
+ mcp_config=mcp_config,
74
+ tools_dir=cfg.dashboard.tools_dir,
75
+ tools_enabled=cfg.dashboard.tools_enabled,
76
+ tools_disabled=cfg.dashboard.tools_disabled,
77
+ )
78
+
79
+ # Wire the tool audit log
80
+ from .dynamic_tools.tool_audit_log import ToolAuditLog
81
+ app.state.audit_log = ToolAuditLog()
82
+
83
+ async with AsyncExitStack() as _stack:
84
+ # ── DI clients: one AsyncClient per Seren service ───────────
85
+ svc = cfg.services
86
+ timeout = httpx.Timeout(svc.timeout_seconds)
87
+
88
+ async def _client(base_url: str) -> httpx.AsyncClient:
89
+ c = httpx.AsyncClient(base_url=base_url, timeout=timeout)
90
+ await _stack.enter_async_context(c)
91
+ return c
92
+
93
+ # A base_url-less client for fetch_url absolute gets and for
94
+ # kind=web dynamic tools (their base_url comes from the manifest).
95
+ _general = await _stack.enter_async_context(
96
+ httpx.AsyncClient(timeout=timeout))
97
+
98
+ app.state.di_registry = {
99
+ "memory": await _client(svc.memory_url),
100
+ "runtime_host": await _client(svc.runtime_host_url),
101
+ "searxng": await _client(svc.searxng_url),
102
+ "scheduler": await _client(svc.scheduler_url),
103
+ "config": mcp_config,
104
+ "_dynamic_web_client": _general,
105
+ }
106
+
107
+ # Mount the MCP surface — conditionally, so a missing `mcp`
108
+ # package doesn't crash startup.
109
+ try:
110
+ from .mcp.server import mount_mcp_routes
111
+ mcp_server = mount_mcp_routes(app)
112
+ except ImportError as exc:
113
+ mcp_server = None
114
+ print(f"[seren-workbench] MCP surface not available; HTTP-only mode ({exc})")
115
+ except Exception as exc:
116
+ mcp_server = None
117
+ print(f"[seren-workbench] MCP mount failed: {exc!r} — continuing without MCP")
118
+
119
+ # The streamable-HTTP transport needs its session manager's task
120
+ # group entered explicitly.
121
+ session_manager = getattr(mcp_server, "session_manager", None)
122
+ if session_manager is not None:
123
+ await _stack.enter_async_context(session_manager.run())
124
+ print("[seren-workbench] MCP session manager running")
125
+ yield
126
+
127
+ print("[seren-workbench] shut down")
128
+
129
+ app = FastAPI(
130
+ title="SerenWorkbench",
131
+ description="MCP (Model Context Protocol) server for the Seren stack — "
132
+ "the tool surface LLMs reach through.",
133
+ version=APP_VERSION,
134
+ lifespan=lifespan,
135
+ )
136
+
137
+ # ── Auth + logging stack ───────────────────────────────────────────
138
+ app.add_middleware(bearer_auth_middleware(bearer))
139
+ app.add_middleware(
140
+ RequestLoggingMiddleware,
141
+ service_name="seren-workbench",
142
+ env_prefix="SEREN_WORKBENCH",
143
+ )
144
+
145
+ viewer_dir = Path(__file__).resolve().parent / "viewer" / "ui"
146
+
147
+ # ── The operator dashboard viewer ──────────────────────────────────
148
+ @app.get("/viewer")
149
+ async def viewer():
150
+ """The operator dashboard — carded tool list with enable/disable toggles.
151
+
152
+ Renders the shared SerenMeninges baseplate with cool-grey accent and
153
+ the leaf fragment files from viewer/ui/.
154
+ """
155
+ html = render_from_dir(
156
+ viewer_dir,
157
+ title="SerenWorkbench",
158
+ brand="Seren<b>Workbench</b> · Tool Surface",
159
+ subtitle=f"v{APP_VERSION} · the MCP tool layer",
160
+ accent="#8e9aaf", # cool grey — slate with a hint of blue
161
+ )
162
+ return HTMLResponse(html)
163
+
164
+ # ── Route subpackage mounts ────────────────────────────────────────
165
+ app.include_router(info_routes.router)
166
+ app.include_router(tools_routes.router)
167
+ app.include_router(config_routes.router)
168
+ app.include_router(logs_routes.router)
169
+
170
+ return app
@@ -0,0 +1,156 @@
1
+ """
2
+ seren_workbench.config
3
+ ════════════════════════════════════════════════════════════════════════
4
+
5
+ Service-specific config for the Workbench MCP server. Uses seren_meninges
6
+ shared blocks (ServerConfig, TlsConfig) plus its own server-specific sections:
7
+ tools, dashboard, services, and dynamic_tools.
8
+
9
+ Follows the same pattern as seren_loci.config, seren_memory.config, and
10
+ seren_corpus_callosum.config — the family's lenient-load discipline.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ import yaml
21
+
22
+ from seren_meninges import ServerConfig, TlsConfig
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ # Port 7425 — family convention: memory 7420, loci-v 7421, loci-nv 7422,
27
+ # scc-nv 7423, scc-v 7424, workbench 7425, probe 7430
28
+ DEFAULT_PORT = 7425
29
+
30
+
31
+ @dataclass
32
+ class DashboardConfig:
33
+ """Operator dashboard knobs.
34
+
35
+ tools_enabled / tools_disabled seed the registry's enable state at
36
+ startup (so an operator's disables survive a restart):
37
+ - tools_disabled: these tools start DISABLED.
38
+ - tools_enabled: if non-empty, it is an ALLOWLIST — every tool NOT
39
+ named here starts disabled. Empty list = everything enabled.
40
+ """
41
+ enabled: bool = True
42
+ tools_dir: str = "/opt/seren/tools"
43
+ tools_enabled: list[str] = field(default_factory=lambda: [])
44
+ tools_disabled: list[str] = field(default_factory=lambda: [])
45
+
46
+ @classmethod
47
+ def from_dict(cls, d: Optional[dict[str, Any]]) -> "DashboardConfig":
48
+ d = d or {}
49
+ return cls(
50
+ enabled=bool(d.get("enabled", True)),
51
+ tools_dir=str(d.get("tools_dir", "/opt/seren/tools")),
52
+ tools_enabled=list(d.get("tools_enabled", [])),
53
+ tools_disabled=list(d.get("tools_disabled", [])),
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class ServicesConfig:
59
+ """Base URLs for the Seren services the builtin tools reach through.
60
+
61
+ These are the DI targets: each builtin tool takes an httpx.AsyncClient
62
+ named after a service (memory, runtime_host, searxng, scheduler); the
63
+ app builds one client per URL here and injects it by parameter name.
64
+
65
+ Defaults are localhost + the family port convention, so a zero-config
66
+ run on the cluster head Just Works. Point them across the LAN in yaml
67
+ for a split deploy.
68
+ """
69
+ memory_url: str = "http://127.0.0.1:7420" # SerenMemory
70
+ runtime_host_url: str = "http://127.0.0.1:6361" # SerenRuntimeHost (cluster head)
71
+ searxng_url: str = "http://127.0.0.1:8080" # SearXNG metasearch
72
+ scheduler_url: str = "http://127.0.0.1:6361" # scheduler surface (RuntimeHost today)
73
+ timeout_seconds: float = 15.0 # per-request client timeout
74
+
75
+ @classmethod
76
+ def from_dict(cls, d: Optional[dict[str, Any]]) -> "ServicesConfig":
77
+ d = d or {}
78
+ out = cls()
79
+ out.memory_url = str(d.get("memory_url", out.memory_url))
80
+ out.runtime_host_url = str(d.get("runtime_host_url", out.runtime_host_url))
81
+ out.searxng_url = str(d.get("searxng_url", out.searxng_url))
82
+ out.scheduler_url = str(d.get("scheduler_url", out.scheduler_url))
83
+ try:
84
+ out.timeout_seconds = float(d.get("timeout_seconds", out.timeout_seconds))
85
+ except (TypeError, ValueError):
86
+ pass # lenient: unparseable timeout keeps the default
87
+ return out
88
+
89
+
90
+ @dataclass
91
+ class WorkbenchConfig:
92
+ """The top-level config, composed from shared blocks + service blocks."""
93
+ server: ServerConfig = field(default_factory=lambda: ServerConfig(port=DEFAULT_PORT))
94
+ tls: TlsConfig = field(default_factory=TlsConfig)
95
+ dashboard: DashboardConfig = field(default_factory=DashboardConfig)
96
+ services: ServicesConfig = field(default_factory=ServicesConfig)
97
+ # The yaml file this config was loaded from (None = defaults/env only).
98
+ # Threaded into McpConfig.load() so the server block and the tools block
99
+ # always come from the SAME file — no CWD-vs-argv[0] split brain.
100
+ source_path: Optional[str] = None
101
+
102
+
103
+ def _apply_env_overrides(cfg: WorkbenchConfig) -> WorkbenchConfig:
104
+ """SEREN_WORKBENCH_* env wins last."""
105
+ env = os.environ
106
+ if v := env.get("SEREN_WORKBENCH_HOST"):
107
+ cfg.server.host = v
108
+ if v := env.get("SEREN_WORKBENCH_PORT"):
109
+ try:
110
+ cfg.server.port = int(v)
111
+ except ValueError:
112
+ log.warning("SEREN_WORKBENCH_PORT=%r is not an int; keeping %s", v, cfg.server.port)
113
+ if v := env.get("SEREN_WORKBENCH_BEARER_TOKEN"):
114
+ cfg.server.bearer_token = v
115
+ if v := env.get("SEREN_WORKBENCH_BEARER_TOKEN_ENV"):
116
+ cfg.server.bearer_token_env = v
117
+ if v := env.get("SEREN_WORKBENCH_BEARER_TOKEN_KEYRING"):
118
+ cfg.server.bearer_token_keyring = v
119
+ if v := env.get("SEREN_WORKBENCH_TRUST_SYSTEM_STORE"):
120
+ cfg.tls.trust_system_store = v.lower() in ("1", "true", "yes", "on")
121
+ if v := env.get("SEREN_WORKBENCH_TOOLS_DIR"):
122
+ cfg.dashboard.tools_dir = v
123
+ if v := env.get("SEREN_WORKBENCH_MEMORY_URL"):
124
+ cfg.services.memory_url = v
125
+ if v := env.get("SEREN_WORKBENCH_RUNTIME_HOST_URL"):
126
+ cfg.services.runtime_host_url = v
127
+ if v := env.get("SEREN_WORKBENCH_SEARXNG_URL"):
128
+ cfg.services.searxng_url = v
129
+ if v := env.get("SEREN_WORKBENCH_SCHEDULER_URL"):
130
+ cfg.services.scheduler_url = v
131
+ return cfg
132
+
133
+
134
+ def load_config(path: Optional[str] = None) -> WorkbenchConfig:
135
+ """Defaults -> yaml -> env (later wins). A missing file is fine — defaults
136
+ + env is a valid zero-config run."""
137
+ data: dict[str, Any] = {}
138
+ candidate = path or os.environ.get("SEREN_WORKBENCH_CONFIG") or "seren-workbench.yaml"
139
+ cfg_path = Path(os.path.expanduser(candidate))
140
+ source_path: Optional[str] = None
141
+ if cfg_path.is_file():
142
+ try:
143
+ with open(cfg_path) as f:
144
+ data = yaml.safe_load(f) or {}
145
+ source_path = str(cfg_path)
146
+ except Exception: # noqa: BLE001
147
+ data = {}
148
+
149
+ server = ServerConfig.from_dict(data.get("server"), default_port=DEFAULT_PORT)
150
+ tls = TlsConfig.from_dict(data.get("tls"))
151
+ dashboard = DashboardConfig.from_dict(data.get("dashboard"))
152
+ services = ServicesConfig.from_dict(data.get("services"))
153
+
154
+ cfg = WorkbenchConfig(server=server, tls=tls, dashboard=dashboard,
155
+ services=services, source_path=source_path)
156
+ return _apply_env_overrides(cfg)
@@ -0,0 +1,10 @@
1
+ """
2
+ seren_workbench.dynamic_tools — YAML-defined plug-and-play tool dispatchers.
3
+
4
+ Dynamic tools are loaded from YAML manifests in a tools/ directory and
5
+ dispatched via web_dispatcher (HTTP calls) or process_dispatcher (subprocess
6
+ calls). This module mirrors the original dynamic_tools/ package at the
7
+ project root, now moved inside the seren_workbench package for family
8
+ alignment.
9
+ """
10
+ from __future__ import annotations
@@ -0,0 +1,139 @@
1
+ # ════════════════════════════════════════════════════════════════════════
2
+ # DynamicToolRegistry - state tracker for plug-and-play YAML tools.
3
+ #
4
+ # ARCHITECTURAL NOTE (the v1 reality)
5
+ #
6
+ # In v1, the actual live tool surface is FIXED at app startup. This
7
+ # registry holds the LoadResult captured at startup and provides
8
+ # RescanDiskAsync() that re-reads tools/ and updates the "current disk
9
+ # state" snapshot. /reload calls this - it does NOT change the live
10
+ # tool surface; that needs a restart.
11
+ # ════════════════════════════════════════════════════════════════════════
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import os
17
+ import sys
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import List
21
+
22
+ from .manifest_loader import LoadResult, ManifestLoader
23
+
24
+
25
+ @dataclass
26
+ class LoadedToolInfo:
27
+ name: str = ""
28
+ source: str = ""
29
+
30
+
31
+ @dataclass
32
+ class SkipInfo:
33
+ name: str = ""
34
+ reason: str = ""
35
+
36
+
37
+ @dataclass
38
+ class FileFailureInfo:
39
+ file: str = ""
40
+ error: str = ""
41
+
42
+
43
+ @dataclass
44
+ class RegistrySnapshot:
45
+ tools_dir: str = ""
46
+ live: List[LoadedToolInfo] = field(default_factory=list)
47
+ on_disk: List[LoadedToolInfo] = field(default_factory=list)
48
+ skipped: List[SkipInfo] = field(default_factory=list)
49
+ failed_files: List[FileFailureInfo] = field(default_factory=list)
50
+ warnings: List[str] = field(default_factory=list)
51
+ restart_pending: bool = False
52
+ would_add_on_restart: List[str] = field(default_factory=list)
53
+ would_remove_on_restart: List[str] = field(default_factory=list)
54
+
55
+
56
+ class DynamicToolRegistry:
57
+ def __init__(self, tools_dir: str, initial_load: LoadResult) -> None:
58
+ self._tools_dir = tools_dir
59
+ self._loader = ManifestLoader()
60
+ self._initial_load = initial_load
61
+ self._current_disk_state = initial_load
62
+ self._lock = asyncio.Lock()
63
+ self._log_startup_summary(initial_load)
64
+
65
+ async def rescan_disk_async(self) -> RegistrySnapshot:
66
+ async with self._lock:
67
+ self._current_disk_state = self._loader.load_directory(self._tools_dir)
68
+ print(
69
+ f"[mcp-registry] disk rescan: "
70
+ f"parsed={len(self._current_disk_state.resolved_inline_tools)} "
71
+ f"skipped={len(self._current_disk_state.skipped_tools)} "
72
+ f"failed_files={len(self._current_disk_state.failed_files)}",
73
+ file=sys.stderr,
74
+ )
75
+ return self._build_snapshot()
76
+
77
+ def current_snapshot(self) -> RegistrySnapshot:
78
+ return self._build_snapshot()
79
+
80
+ # -- Helpers --
81
+
82
+ def _log_startup_summary(self, result: LoadResult) -> None:
83
+ print(
84
+ f"[mcp-registry] startup: loaded={len(result.resolved_inline_tools)} "
85
+ f"from {self._tools_dir} "
86
+ f"(skipped={len(result.skipped_tools)}, "
87
+ f"failed_files={len(result.failed_files)})",
88
+ file=sys.stderr,
89
+ )
90
+ for name, reason in result.skipped_tools:
91
+ print(f"[mcp-registry] skipped: {reason}", file=sys.stderr)
92
+ for path, err in result.failed_files:
93
+ print(f"[mcp-registry] failed: {Path(path).name}: {err}", file=sys.stderr)
94
+ for warning in result.warnings:
95
+ print(f"[mcp-registry] warning: {warning}", file=sys.stderr)
96
+
97
+ def _build_snapshot(self) -> RegistrySnapshot:
98
+ live_names = set(
99
+ t.name or "(unnamed)"
100
+ for t, _, _ in self._initial_load.resolved_inline_tools
101
+ )
102
+ disk_names = set(
103
+ t.name or "(unnamed)"
104
+ for t, _, _ in self._current_disk_state.resolved_inline_tools
105
+ )
106
+
107
+ would_add = sorted(disk_names - live_names)
108
+ would_remove = sorted(live_names - disk_names)
109
+
110
+ def to_info(t: tuple) -> LoadedToolInfo:
111
+ entry, _, source = t
112
+ return LoadedToolInfo(
113
+ name=entry.name or "(unnamed)",
114
+ source=Path(source).name,
115
+ )
116
+
117
+ return RegistrySnapshot(
118
+ tools_dir=self._tools_dir,
119
+ live=sorted(
120
+ [to_info(t) for t in self._initial_load.resolved_inline_tools],
121
+ key=lambda x: x.name,
122
+ ),
123
+ on_disk=sorted(
124
+ [to_info(t) for t in self._current_disk_state.resolved_inline_tools],
125
+ key=lambda x: x.name,
126
+ ),
127
+ skipped=[
128
+ SkipInfo(name=n, reason=r)
129
+ for n, r in self._current_disk_state.skipped_tools
130
+ ],
131
+ failed_files=[
132
+ FileFailureInfo(file=Path(p).name, error=e)
133
+ for p, e in self._current_disk_state.failed_files
134
+ ],
135
+ warnings=list(self._current_disk_state.warnings),
136
+ restart_pending=bool(would_add or would_remove),
137
+ would_add_on_restart=would_add,
138
+ would_remove_on_restart=would_remove,
139
+ )