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.
- seren_workbench/__init__.py +32 -0
- seren_workbench/__main__.py +56 -0
- seren_workbench/_version.py +24 -0
- seren_workbench/app.py +170 -0
- seren_workbench/config.py +156 -0
- seren_workbench/dynamic_tools/__init__.py +10 -0
- seren_workbench/dynamic_tools/dynamic_tool_registry.py +139 -0
- seren_workbench/dynamic_tools/manifest_loader.py +385 -0
- seren_workbench/dynamic_tools/manifest_models.py +100 -0
- seren_workbench/dynamic_tools/param_subst.py +110 -0
- seren_workbench/dynamic_tools/process_dispatcher.py +116 -0
- seren_workbench/dynamic_tools/tool_audit_log.py +60 -0
- seren_workbench/dynamic_tools/web_dispatcher.py +122 -0
- seren_workbench/dynamic_tools/yaml_dispatched_tool.py +282 -0
- seren_workbench/mcp/__init__.py +8 -0
- seren_workbench/mcp/server.py +412 -0
- seren_workbench/models/__init__.py +4 -0
- seren_workbench/models/tools/__init__.py +0 -0
- seren_workbench/models/tools/cluster_tools.py +101 -0
- seren_workbench/models/tools/consolidator_tools.py +104 -0
- seren_workbench/models/tools/ensure_service_running_tool.py +151 -0
- seren_workbench/models/tools/introspection_and_agency_tools.py +177 -0
- seren_workbench/models/tools/log_tools.py +113 -0
- seren_workbench/models/tools/memory_tools.py +164 -0
- seren_workbench/models/tools/model_tools.py +89 -0
- seren_workbench/models/tools/scheduler_tools.py +178 -0
- seren_workbench/models/tools/self_tools.py +161 -0
- seren_workbench/models/tools/service_control_tools.py +138 -0
- seren_workbench/models/tools/time_tools.py +49 -0
- seren_workbench/models/tools/wait_for_service_tool.py +110 -0
- seren_workbench/models/tools/web_tools.py +235 -0
- seren_workbench/models/tools/which_model_tool.py +73 -0
- seren_workbench/routes/__init__.py +7 -0
- seren_workbench/routes/config.py +42 -0
- seren_workbench/routes/info.py +35 -0
- seren_workbench/routes/logs.py +32 -0
- seren_workbench/routes/tools.py +78 -0
- seren_workbench/tool_config/__init__.py +12 -0
- seren_workbench/tool_config/mcp_config.py +94 -0
- seren_workbench/tool_config/tool_section.py +75 -0
- seren_workbench/tool_registry.py +300 -0
- seren_workbench/viewer/ui/body.html +79 -0
- seren_workbench/viewer/ui/header_aside.html +2 -0
- seren_workbench/viewer/ui/scripts.js +345 -0
- seren_workbench/viewer/ui/styles.css +326 -0
- seren_workbench/viewer/ui/tabs.html +4 -0
- seren_workbench-0.2.0.dist-info/METADATA +22 -0
- seren_workbench-0.2.0.dist-info/RECORD +52 -0
- seren_workbench-0.2.0.dist-info/WHEEL +5 -0
- seren_workbench-0.2.0.dist-info/entry_points.txt +2 -0
- seren_workbench-0.2.0.dist-info/licenses/LICENSE +674 -0
- 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
|
+
)
|