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.
- mcp_switchboard_hub-0.2.0/.gitignore +13 -0
- mcp_switchboard_hub-0.2.0/PKG-INFO +62 -0
- mcp_switchboard_hub-0.2.0/README.md +37 -0
- mcp_switchboard_hub-0.2.0/pyproject.toml +43 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/__init__.py +0 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/__main__.py +4 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/api.py +321 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/app.py +120 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/calls.py +333 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/cli.py +156 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/config.py +101 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/envconf.py +117 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/loki.py +296 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/mcp_endpoint.py +220 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/metrics.py +163 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/protocol.py +93 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/registry.py +235 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/service.py +237 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/tunnel.py +364 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/web/app.js +973 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/web/favicon.svg +9 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/web/index.html +95 -0
- mcp_switchboard_hub-0.2.0/src/mcp_switchboard_hub/web/style.css +471 -0
- mcp_switchboard_hub-0.2.0/tests/test_api.py +435 -0
- mcp_switchboard_hub-0.2.0/tests/test_calls.py +276 -0
- mcp_switchboard_hub-0.2.0/tests/test_loki.py +440 -0
- mcp_switchboard_hub-0.2.0/tests/test_metrics.py +223 -0
- mcp_switchboard_hub-0.2.0/tests/test_protocol_sync.py +33 -0
|
@@ -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"
|
|
File without changes
|
|
@@ -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)
|