mcp-switchboard-hub 0.2.0__tar.gz

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 (28) hide show
  1. mcp_switchboard_hub-0.2.0/.gitignore +13 -0
  2. mcp_switchboard_hub-0.2.0/PKG-INFO +62 -0
  3. mcp_switchboard_hub-0.2.0/README.md +37 -0
  4. mcp_switchboard_hub-0.2.0/pyproject.toml +43 -0
  5. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/__init__.py +0 -0
  6. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/__main__.py +4 -0
  7. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/api.py +321 -0
  8. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/app.py +120 -0
  9. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/calls.py +333 -0
  10. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/cli.py +156 -0
  11. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/config.py +101 -0
  12. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/envconf.py +117 -0
  13. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/loki.py +296 -0
  14. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/mcp_endpoint.py +220 -0
  15. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/metrics.py +163 -0
  16. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/protocol.py +93 -0
  17. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/registry.py +235 -0
  18. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/service.py +237 -0
  19. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/tunnel.py +364 -0
  20. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/web/app.js +973 -0
  21. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/web/favicon.svg +9 -0
  22. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/web/index.html +95 -0
  23. mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/web/style.css +471 -0
  24. mcp_switchboard_hub-0.2.0/tests/test_api.py +435 -0
  25. mcp_switchboard_hub-0.2.0/tests/test_calls.py +276 -0
  26. mcp_switchboard_hub-0.2.0/tests/test_loki.py +440 -0
  27. mcp_switchboard_hub-0.2.0/tests/test_metrics.py +223 -0
  28. mcp_switchboard_hub-0.2.0/tests/test_protocol_sync.py +33 -0
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .env
12
+ result
13
+ result-*
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-switchboard-hub
3
+ Version: 0.2.0
4
+ Summary: Aggregating MCP gateway for outbound tunnels, with a web console, call log, and Prometheus/Loki export
5
+ Project-URL: Homepage, https://github.com/AkosPapp/mcp-switchboard
6
+ Project-URL: Repository, https://github.com/AkosPapp/mcp-switchboard
7
+ Author: Akos Papp
8
+ License: MIT
9
+ Keywords: gateway,mcp,model-context-protocol,n8n,reverse-proxy
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: aiosqlite>=0.20
15
+ Requires-Dist: fastapi>=0.115
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: mcp<3,>=2.2
18
+ Requires-Dist: prometheus-client>=0.20
19
+ Requires-Dist: uvicorn>=0.30
20
+ Requires-Dist: websockets>=12
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
23
+ Requires-Dist: pytest>=8; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # mcp-switchboard-hub
27
+
28
+ The server half of [mcp-switchboard](https://github.com/AkosPapp/mcp-switchboard).
29
+
30
+ It accepts outbound WebSocket tunnels from machines running local stdio MCP
31
+ servers, speaks MCP to each tunnelled server, and re-serves every tool from one
32
+ place:
33
+
34
+ - **`/mcp`** — Streamable HTTP MCP endpoint for consumers (n8n, agents), also
35
+ available scoped per machine (`/mcp/host/{label}`) or per server
36
+ (`/mcp/host/{label}/server/{server}`).
37
+ - **`/`** — a web console to browse connections, servers and tools, call any tool
38
+ by hand, and read back every call.
39
+ - **`/api/*`** — the JSON API behind the console.
40
+ - **`/metrics`** — Prometheus.
41
+ - Loki export and a SQLite call log.
42
+
43
+ ## Running
44
+
45
+ ```sh
46
+ pip install mcp-switchboard-hub
47
+ MCP_SWITCHBOARD_TUNNEL_TOKEN=... mcp-switchboard-hub
48
+ ```
49
+
50
+ It listens on two ports on purpose: the tunnel endpoint (default
51
+ `127.0.0.1:8097`) is the only thing meant to face the internet and always
52
+ requires a bearer token, while the console, API, `/mcp` and `/metrics` live on a
53
+ second listener (default `127.0.0.1:8099`) that binds loopback and is
54
+ unauthenticated by default.
55
+
56
+ Everything is configured through `MCP_SWITCHBOARD_*` environment variables, read
57
+ from the environment or a `.env` file. Any value that starts with `/` and points
58
+ at an existing regular file is replaced by that file's contents, so secrets can
59
+ be passed as paths without separate `*_FILE` variables.
60
+
61
+ See the [main README](../README.md) for the full picture, and
62
+ [docs/PROTOCOL.md](../docs/PROTOCOL.md) for the tunnel wire format.
@@ -0,0 +1,37 @@
1
+ # mcp-switchboard-hub
2
+
3
+ The server half of [mcp-switchboard](https://github.com/AkosPapp/mcp-switchboard).
4
+
5
+ It accepts outbound WebSocket tunnels from machines running local stdio MCP
6
+ servers, speaks MCP to each tunnelled server, and re-serves every tool from one
7
+ place:
8
+
9
+ - **`/mcp`** — Streamable HTTP MCP endpoint for consumers (n8n, agents), also
10
+ available scoped per machine (`/mcp/host/{label}`) or per server
11
+ (`/mcp/host/{label}/server/{server}`).
12
+ - **`/`** — a web console to browse connections, servers and tools, call any tool
13
+ by hand, and read back every call.
14
+ - **`/api/*`** — the JSON API behind the console.
15
+ - **`/metrics`** — Prometheus.
16
+ - Loki export and a SQLite call log.
17
+
18
+ ## Running
19
+
20
+ ```sh
21
+ pip install mcp-switchboard-hub
22
+ MCP_SWITCHBOARD_TUNNEL_TOKEN=... mcp-switchboard-hub
23
+ ```
24
+
25
+ It listens on two ports on purpose: the tunnel endpoint (default
26
+ `127.0.0.1:8097`) is the only thing meant to face the internet and always
27
+ requires a bearer token, while the console, API, `/mcp` and `/metrics` live on a
28
+ second listener (default `127.0.0.1:8099`) that binds loopback and is
29
+ unauthenticated by default.
30
+
31
+ Everything is configured through `MCP_SWITCHBOARD_*` environment variables, read
32
+ from the environment or a `.env` file. Any value that starts with `/` and points
33
+ at an existing regular file is replaced by that file's contents, so secrets can
34
+ be passed as paths without separate `*_FILE` variables.
35
+
36
+ See the [main README](../README.md) for the full picture, and
37
+ [docs/PROTOCOL.md](../docs/PROTOCOL.md) for the tunnel wire format.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-switchboard-hub"
7
+ version = "0.2.0"
8
+ description = "Aggregating MCP gateway for outbound tunnels, with a web console, call log, and Prometheus/Loki export"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Akos Papp" }]
13
+ keywords = ["mcp", "model-context-protocol", "gateway", "reverse-proxy", "n8n"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=2.2,<3",
21
+ "fastapi>=0.115",
22
+ "uvicorn>=0.30",
23
+ "websockets>=12",
24
+ "prometheus-client>=0.20",
25
+ "aiosqlite>=0.20",
26
+ "httpx>=0.27",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=8", "pytest-asyncio>=0.23"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/AkosPapp/mcp-switchboard"
34
+ Repository = "https://github.com/AkosPapp/mcp-switchboard"
35
+
36
+ [project.scripts]
37
+ mcp-switchboard-hub = "mcp_switchboard_hub.cli:main"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/mcp_switchboard_hub"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,321 @@
1
+ """HTTP API and web console for the hub.
2
+
3
+ Everything here is a thin shell over ``request.app.state.service``: this module
4
+ holds no state of its own, so it can be mounted by whatever composes the app
5
+ without an initialisation order to get wrong.
6
+
7
+ Two deliberate choices worth knowing about:
8
+
9
+ * A tool that answers with an MCP error is a **successful call that returned an
10
+ error**: 200 with ``status: "error"`` in the body. Only an unknown
11
+ connection/server/tool (``LookupError``) is a 404. Folding the two together
12
+ would make the console report transport failures as tool bugs.
13
+ * The console is served from ``web/`` through ``importlib.resources``, not from
14
+ a path relative to the source tree, so it works the same from a wheel, a Nix
15
+ store path, or a git checkout.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import re
24
+ from contextlib import suppress
25
+ from importlib import resources
26
+ from typing import Any, Dict, Optional, Tuple
27
+
28
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
29
+ from fastapi.responses import HTMLResponse, StreamingResponse
30
+ from pydantic import BaseModel, Field
31
+
32
+ LOG = logging.getLogger(__name__)
33
+
34
+ #: How long the SSE stream may stay silent before it emits a comment. Proxies
35
+ #: and browsers drop idle connections well before a quiet hub would otherwise
36
+ #: say anything.
37
+ KEEPALIVE_SECONDS = 15.0
38
+
39
+ #: Events buffered per SSE client before the oldest are dropped. A reader that
40
+ #: falls this far behind has lost history either way; staying live matters more.
41
+ EVENT_QUEUE_SIZE = 1000
42
+
43
+ DEFAULT_CALL_LIMIT = 100
44
+ MAX_CALL_LIMIT = 1000
45
+
46
+ CALL_SOURCE_CONSOLE = "console"
47
+
48
+ _STREAM_END = object()
49
+
50
+ _SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
51
+
52
+ _CONTENT_TYPES = {
53
+ ".html": "text/html; charset=utf-8",
54
+ ".js": "text/javascript; charset=utf-8",
55
+ ".css": "text/css; charset=utf-8",
56
+ ".json": "application/json",
57
+ ".map": "application/json",
58
+ ".svg": "image/svg+xml",
59
+ ".png": "image/png",
60
+ ".ico": "image/x-icon",
61
+ ".woff2": "font/woff2",
62
+ ".txt": "text/plain; charset=utf-8",
63
+ }
64
+
65
+
66
+ # --------------------------------------------------------------------------
67
+ # service access
68
+ # --------------------------------------------------------------------------
69
+
70
+
71
+ def get_service(request: Request) -> Any:
72
+ """The live service object, or 503 while the app is still starting."""
73
+ service = getattr(request.app.state, "service", None)
74
+ if service is None:
75
+ raise HTTPException(status_code=503, detail="hub service is not ready")
76
+ return service
77
+
78
+
79
+ ServiceDep = Depends(get_service)
80
+
81
+
82
+ def record_json(record: Any) -> Any:
83
+ """Normalise a CallRecord to its JSON form, passing dicts through."""
84
+ to_json = getattr(record, "to_json", None)
85
+ return to_json() if callable(to_json) else record
86
+
87
+
88
+ def _clean(value: Optional[str]) -> Optional[str]:
89
+ """Treat an empty query parameter as an absent filter."""
90
+ if value is None:
91
+ return None
92
+ value = value.strip()
93
+ return value or None
94
+
95
+
96
+ # --------------------------------------------------------------------------
97
+ # static assets
98
+ # --------------------------------------------------------------------------
99
+
100
+
101
+ def web_root():
102
+ """Traversable root of the bundled console."""
103
+ return resources.files(__package__ or "mcp_switchboard_hub").joinpath("web")
104
+
105
+
106
+ def content_type_for(name: str) -> str:
107
+ suffix = name[name.rfind(".") :].lower() if "." in name else ""
108
+ return _CONTENT_TYPES.get(suffix, "application/octet-stream")
109
+
110
+
111
+ def read_web_asset(relative: str) -> Tuple[bytes, str]:
112
+ """Read ``web/<relative>``; 404 on anything that is not a plain asset."""
113
+ parts = [part for part in relative.split("/") if part]
114
+ if not parts or not all(_SAFE_SEGMENT.match(part) for part in parts):
115
+ raise HTTPException(status_code=404, detail="not found")
116
+ node = web_root()
117
+ for part in parts:
118
+ node = node.joinpath(part)
119
+ try:
120
+ payload = node.read_bytes()
121
+ except (FileNotFoundError, NotADirectoryError, IsADirectoryError, PermissionError, OSError):
122
+ raise HTTPException(status_code=404, detail="not found") from None
123
+ return payload, content_type_for(parts[-1])
124
+
125
+
126
+ # --------------------------------------------------------------------------
127
+ # SSE
128
+ # --------------------------------------------------------------------------
129
+
130
+
131
+ def sse_message(event: Any) -> str:
132
+ payload = json.dumps(event, separators=(",", ":"), default=str)
133
+ return f"data: {payload}\n\n"
134
+
135
+
136
+ async def _pump(source: Any, queue: "asyncio.Queue[Any]") -> None:
137
+ """Drain the service subscription into a per-client queue."""
138
+ try:
139
+ async for event in source:
140
+ if queue.full():
141
+ with suppress(asyncio.QueueEmpty):
142
+ queue.get_nowait()
143
+ queue.put_nowait(event)
144
+ except asyncio.CancelledError:
145
+ raise
146
+ except Exception: # a broken subscription ends the stream, not the process
147
+ LOG.exception("event subscription failed")
148
+ finally:
149
+ with suppress(asyncio.QueueFull):
150
+ queue.put_nowait(_STREAM_END)
151
+
152
+
153
+ async def event_stream(request: Request, service: Any):
154
+ """Yield SSE frames from ``service.subscribe()`` until either side stops."""
155
+ queue: "asyncio.Queue[Any]" = asyncio.Queue(maxsize=EVENT_QUEUE_SIZE)
156
+ source = service.subscribe()
157
+ pump = asyncio.ensure_future(_pump(source, queue))
158
+ try:
159
+ # Flush headers immediately so the browser reports the stream as open.
160
+ yield ": connected\n\n"
161
+ while True:
162
+ try:
163
+ event = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_SECONDS)
164
+ except asyncio.TimeoutError:
165
+ if await request.is_disconnected():
166
+ break
167
+ yield ": keepalive\n\n"
168
+ continue
169
+ if event is _STREAM_END:
170
+ break
171
+ yield sse_message(event)
172
+ finally:
173
+ pump.cancel()
174
+ with suppress(asyncio.CancelledError, Exception):
175
+ await pump
176
+ aclose = getattr(source, "aclose", None)
177
+ if callable(aclose):
178
+ with suppress(Exception):
179
+ await aclose()
180
+
181
+
182
+ # --------------------------------------------------------------------------
183
+ # request bodies
184
+ # --------------------------------------------------------------------------
185
+
186
+
187
+ class ToolCallRequest(BaseModel):
188
+ arguments: Dict[str, Any] = Field(default_factory=dict)
189
+
190
+
191
+ # --------------------------------------------------------------------------
192
+ # router
193
+ # --------------------------------------------------------------------------
194
+
195
+
196
+ def create_api_router() -> APIRouter:
197
+ """Build the hub's HTTP surface: JSON API, metrics, and the web console."""
198
+ router = APIRouter()
199
+
200
+ @router.get("/api/connections")
201
+ async def connections(service: Any = ServiceDep) -> Any:
202
+ return service.snapshot()
203
+
204
+ @router.post("/api/connections/{connection_id}/servers/{server}/tools/{tool}/call")
205
+ async def call_tool(
206
+ connection_id: str,
207
+ server: str,
208
+ tool: str,
209
+ body: ToolCallRequest = ToolCallRequest(),
210
+ service: Any = ServiceDep,
211
+ ) -> Any:
212
+ try:
213
+ record = await service.execute_tool_call(
214
+ connection_id=connection_id,
215
+ server=server,
216
+ tool=tool,
217
+ arguments=body.arguments,
218
+ source=CALL_SOURCE_CONSOLE,
219
+ )
220
+ except LookupError as exc:
221
+ raise HTTPException(status_code=404, detail=str(exc) or "unknown tool") from exc
222
+ return record_json(record)
223
+
224
+ @router.post(
225
+ "/api/connections/{connection_id}/servers/{server}/restart",
226
+ status_code=204,
227
+ response_class=Response,
228
+ )
229
+ async def restart_server(connection_id: str, server: str, service: Any = ServiceDep) -> Response:
230
+ try:
231
+ await service.restart_server(connection_id, server)
232
+ except LookupError as exc:
233
+ raise HTTPException(status_code=404, detail=str(exc) or "unknown server") from exc
234
+ return Response(status_code=204)
235
+
236
+ @router.get("/api/calls")
237
+ async def list_calls(
238
+ limit: int = DEFAULT_CALL_LIMIT,
239
+ offset: int = 0,
240
+ server: Optional[str] = None,
241
+ tool: Optional[str] = None,
242
+ status: Optional[str] = None,
243
+ label: Optional[str] = None,
244
+ service: Any = ServiceDep,
245
+ ) -> Any:
246
+ # Clamped rather than rejected: a console asking for too much should get
247
+ # a page of results, not a validation error.
248
+ limit = max(1, min(int(limit), MAX_CALL_LIMIT))
249
+ offset = max(0, int(offset))
250
+ records = await service.calls.list(
251
+ limit=limit,
252
+ offset=offset,
253
+ server=_clean(server),
254
+ tool=_clean(tool),
255
+ status=_clean(status),
256
+ label=_clean(label),
257
+ )
258
+ return {
259
+ "calls": [record_json(record) for record in records],
260
+ "stats": await service.calls.stats(),
261
+ "limit": limit,
262
+ "offset": offset,
263
+ }
264
+
265
+ @router.get("/api/calls/{call_id}")
266
+ async def get_call(call_id: str, service: Any = ServiceDep) -> Any:
267
+ record = await service.calls.get(call_id)
268
+ if record is None:
269
+ raise HTTPException(status_code=404, detail="unknown call")
270
+ return record_json(record)
271
+
272
+ @router.get("/api/events")
273
+ async def events(request: Request, service: Any = ServiceDep) -> StreamingResponse:
274
+ return StreamingResponse(
275
+ event_stream(request, service),
276
+ media_type="text/event-stream",
277
+ headers={
278
+ "Cache-Control": "no-cache, no-transform",
279
+ "Connection": "keep-alive",
280
+ # nginx buffers streamed responses unless told not to.
281
+ "X-Accel-Buffering": "no",
282
+ },
283
+ )
284
+
285
+ @router.get("/metrics")
286
+ async def metrics(service: Any = ServiceDep) -> Response:
287
+ rendered = service.metrics.render()
288
+ if asyncio.iscoroutine(rendered):
289
+ rendered = await rendered
290
+ payload, content_type = rendered
291
+ return Response(content=payload, media_type=content_type)
292
+
293
+ @router.get("/", response_class=HTMLResponse, include_in_schema=False)
294
+ async def console() -> HTMLResponse:
295
+ payload, _ = read_web_asset("index.html")
296
+ return HTMLResponse(content=payload.decode("utf-8"), headers={"Cache-Control": "no-cache"})
297
+
298
+ @router.get("/static/{path:path}", include_in_schema=False)
299
+ async def static_asset(path: str) -> Response:
300
+ payload, content_type = read_web_asset(path)
301
+ return Response(
302
+ content=payload,
303
+ media_type=content_type,
304
+ headers={"Cache-Control": "no-cache"},
305
+ )
306
+
307
+ return router
308
+
309
+
310
+ __all__ = [
311
+ "CALL_SOURCE_CONSOLE",
312
+ "DEFAULT_CALL_LIMIT",
313
+ "KEEPALIVE_SECONDS",
314
+ "MAX_CALL_LIMIT",
315
+ "ToolCallRequest",
316
+ "create_api_router",
317
+ "get_service",
318
+ "read_web_asset",
319
+ "record_json",
320
+ "web_root",
321
+ ]
@@ -0,0 +1,120 @@
1
+ """Builds the two listeners.
2
+
3
+ They are separate apps on separate ports for a deliberate reason: only the tunnel
4
+ endpoint is meant to be reachable from the internet, and it always requires a
5
+ token. The console, the API, the MCP endpoint and /metrics live on a second
6
+ listener that binds loopback, so they are not protected by a password - they are
7
+ simply not reachable. That is a stronger guarantee than a shared secret, and it
8
+ is why the private listener is unauthenticated by default.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import logging
15
+ from dataclasses import dataclass
16
+ from typing import AsyncIterator
17
+
18
+ from fastapi import FastAPI, Request, WebSocket
19
+ from fastapi.responses import JSONResponse
20
+
21
+ from . import protocol
22
+ from .api import create_api_router
23
+ from .calls import CallStore
24
+ from .config import Settings
25
+ from .loki import LokiExporter
26
+ from .mcp_endpoint import McpEndpoint
27
+ from .metrics import Metrics
28
+ from .registry import Registry
29
+ from .service import Service
30
+ from .tunnel import TunnelHandler
31
+
32
+ LOGGER = logging.getLogger("mcp_switchboard_hub.app")
33
+
34
+
35
+ @dataclass
36
+ class Hub:
37
+ settings: Settings
38
+ service: Service
39
+ mcp: McpEndpoint
40
+ tunnel_app: FastAPI
41
+ private_app: FastAPI
42
+
43
+ @contextlib.asynccontextmanager
44
+ async def lifespan(self) -> AsyncIterator["Hub"]:
45
+ """Own everything process-wide, rather than per-app.
46
+
47
+ The MCP session manager's run() must be entered exactly once, and both
48
+ apps share one Service, so the lifecycle belongs here and not in either
49
+ app's own lifespan.
50
+ """
51
+ async with contextlib.AsyncExitStack() as stack:
52
+ await self.service.start()
53
+ stack.push_async_callback(self.service.close)
54
+ await stack.enter_async_context(self.mcp.run())
55
+ yield self
56
+
57
+
58
+ def build_hub(settings: Settings) -> Hub:
59
+ registry = Registry()
60
+ metrics = Metrics()
61
+ calls = CallStore(settings.db_path, retention_days=settings.retention_days, max_rows=settings.max_rows)
62
+ loki = LokiExporter(
63
+ url=settings.loki_url,
64
+ labels=settings.loki_labels,
65
+ enabled=settings.loki_enabled,
66
+ metrics=metrics,
67
+ )
68
+
69
+ service = Service(settings, registry, calls, metrics, loki)
70
+ mcp = McpEndpoint(registry, service)
71
+ service.mcp = mcp
72
+
73
+ handler = TunnelHandler(
74
+ registry,
75
+ token=settings.tunnel_token,
76
+ tools_timeout=settings.tools_timeout,
77
+ on_change=service.on_topology_change,
78
+ metrics=metrics,
79
+ )
80
+
81
+ tunnel_app = FastAPI(title="mcp-switchboard tunnel", docs_url=None, redoc_url=None)
82
+
83
+ @tunnel_app.websocket(protocol.TUNNEL_PATH)
84
+ async def tunnel_endpoint(websocket: WebSocket) -> None: # pragma: no cover - exercised end to end
85
+ await handler.handle(websocket)
86
+
87
+ @tunnel_app.get("/health")
88
+ async def tunnel_health() -> dict:
89
+ return {"status": "ok"}
90
+
91
+ private_app = FastAPI(title="mcp-switchboard", docs_url=None, redoc_url=None)
92
+ private_app.state.service = service
93
+
94
+ if settings.private_token:
95
+ _require_token(private_app, settings.private_token)
96
+
97
+ private_app.include_router(create_api_router())
98
+ for route in mcp.routes():
99
+ private_app.router.routes.append(route)
100
+
101
+ @private_app.get("/health")
102
+ async def private_health() -> dict:
103
+ return {"status": "ok"}
104
+
105
+ return Hub(settings=settings, service=service, mcp=mcp, tunnel_app=tunnel_app, private_app=private_app)
106
+
107
+
108
+ def _require_token(app: FastAPI, token: str) -> None:
109
+ """Optional bearer auth for the private listener, for when it is exposed anyway."""
110
+ import hmac
111
+
112
+ @app.middleware("http")
113
+ async def check_token(request: Request, call_next):
114
+ if request.url.path == "/health":
115
+ return await call_next(request)
116
+ header = request.headers.get("authorization", "")
117
+ supplied = header[len("Bearer ") :].strip() if header.startswith("Bearer ") else ""
118
+ if not hmac.compare_digest(supplied, token):
119
+ return JSONResponse({"detail": "unauthorized"}, status_code=401)
120
+ return await call_next(request)