mcp-combiner 0.6.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_combiner-0.6.0/.gitignore +19 -0
- mcp_combiner-0.6.0/LICENSE +21 -0
- mcp_combiner-0.6.0/PKG-INFO +66 -0
- mcp_combiner-0.6.0/README.md +43 -0
- mcp_combiner-0.6.0/mcp_combiner/__init__.py +3 -0
- mcp_combiner-0.6.0/mcp_combiner/__main__.py +434 -0
- mcp_combiner-0.6.0/mcp_combiner/auth.py +1283 -0
- mcp_combiner-0.6.0/mcp_combiner/config.py +451 -0
- mcp_combiner-0.6.0/mcp_combiner/connections.py +601 -0
- mcp_combiner-0.6.0/mcp_combiner/fastvalidate.py +170 -0
- mcp_combiner-0.6.0/mcp_combiner/meta_tools.py +487 -0
- mcp_combiner-0.6.0/mcp_combiner/nvim_channel.py +275 -0
- mcp_combiner-0.6.0/mcp_combiner/nvim_proxy.py +361 -0
- mcp_combiner-0.6.0/mcp_combiner/server.py +1335 -0
- mcp_combiner-0.6.0/mcp_combiner/sharedserver.py +407 -0
- mcp_combiner-0.6.0/pyproject.toml +91 -0
- mcp_combiner-0.6.0/tests/fixtures/servers.json +42 -0
- mcp_combiner-0.6.0/tests/test_auth.py +1190 -0
- mcp_combiner-0.6.0/tests/test_config.py +409 -0
- mcp_combiner-0.6.0/tests/test_nvim_channel.py +168 -0
- mcp_combiner-0.6.0/tests/test_nvim_e2e.py +211 -0
- mcp_combiner-0.6.0/tests/test_reload_config.py +258 -0
- mcp_combiner-0.6.0/tests/test_session_filter.py +657 -0
- mcp_combiner-0.6.0/tests/test_sharedserver.py +256 -0
- mcp_combiner-0.6.0/uv.lock +1621 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/combiner/mcp_combiner/__pycache__
|
|
2
|
+
/combiner/tests/__pycache__
|
|
3
|
+
|
|
4
|
+
# Quarto docs build artifacts
|
|
5
|
+
/_site/
|
|
6
|
+
/.quarto/
|
|
7
|
+
**/*.quarto_ipynb
|
|
8
|
+
_quarto-deploy.yml
|
|
9
|
+
|
|
10
|
+
# Python virtualenvs, caches, build output (combiner package)
|
|
11
|
+
.venv/
|
|
12
|
+
**/.venv/
|
|
13
|
+
**/.venv.backup*/
|
|
14
|
+
**/__pycache__/
|
|
15
|
+
**/.mypy_cache/
|
|
16
|
+
**/.pytest_cache/
|
|
17
|
+
**/.ruff_cache/
|
|
18
|
+
/combiner/dist/
|
|
19
|
+
*.egg-info/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 George Harker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-combiner
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: An MCP aggregator — fronts multiple MCP servers behind a single Streamable HTTP endpoint, shareable across clients.
|
|
5
|
+
Project-URL: Homepage, https://github.com/georgeharker/mcp-companion
|
|
6
|
+
Project-URL: Repository, https://github.com/georgeharker/mcp-companion
|
|
7
|
+
Project-URL: Issues, https://github.com/georgeharker/mcp-companion/issues
|
|
8
|
+
Author-email: George Harker <george@georgeharker.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aggregator,fastmcp,mcp,neovim,proxy
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: fastmcp<4,>=3.0
|
|
21
|
+
Requires-Dist: pynvim>=0.6.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# mcp-combiner
|
|
25
|
+
|
|
26
|
+
An **MCP aggregator** — fronts multiple MCP servers behind a single Streamable HTTP endpoint, so
|
|
27
|
+
one connection exposes every backend server's tools. Built on
|
|
28
|
+
[FastMCP](https://github.com/jlowin/fastmcp). Shareable across clients (via `sharedserver`), it
|
|
29
|
+
powers the [mcp-companion](https://github.com/georgeharker/mcp-companion) Neovim plugin and the
|
|
30
|
+
[`claude-mcp-combiner`](https://github.com/georgeharker/claude-mcp-combiner) Claude Code plugin, and works standalone with any MCP client.
|
|
31
|
+
|
|
32
|
+
> PyPI package · command · import package: **`mcp-combiner`** / `mcp-combiner` / `mcp_combiner`.
|
|
33
|
+
|
|
34
|
+
> ⚠️ **Renamed from `mcp-bridge`.** If you ran an earlier build:
|
|
35
|
+
> - command/import are now `mcp-combiner` / `mcp_combiner`; reinstall:
|
|
36
|
+
> `uv tool uninstall mcp-bridge` then `uv tool install …` (see Install below).
|
|
37
|
+
> - config env vars `MCP_BRIDGE_*` → `MCP_COMBINER_*` (and `MCP_COMPANION_COMBINER_URL` →
|
|
38
|
+
> `MCP_COMPANION_COMBINER_URL`).
|
|
39
|
+
> - OAuth token storage moved to `~/.cache/mcp-combiner/` — you'll **re-authenticate each MCP
|
|
40
|
+
> server once** (old tokens under `~/.cache/mcp-companion/` are no longer read).
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
Needs only [uv](https://docs.astral.sh/uv/) — `uvx` fetches and runs it, no venv to manage:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uvx mcp-combiner --help # once published to PyPI
|
|
48
|
+
# before PyPI (or to track main) — the package lives in the combiner/ subdirectory:
|
|
49
|
+
uvx --from "git+https://github.com/georgeharker/mcp-companion#subdirectory=combiner" mcp-combiner
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or install it: `uv pip install mcp-combiner` (PyPI), or from the repo subdir
|
|
53
|
+
`uv pip install "git+https://github.com/georgeharker/mcp-companion#subdirectory=combiner"`.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
mcp-combiner --config /path/to/servers.json --port 9741
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv sync
|
|
65
|
+
pytest
|
|
66
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# mcp-combiner
|
|
2
|
+
|
|
3
|
+
An **MCP aggregator** — fronts multiple MCP servers behind a single Streamable HTTP endpoint, so
|
|
4
|
+
one connection exposes every backend server's tools. Built on
|
|
5
|
+
[FastMCP](https://github.com/jlowin/fastmcp). Shareable across clients (via `sharedserver`), it
|
|
6
|
+
powers the [mcp-companion](https://github.com/georgeharker/mcp-companion) Neovim plugin and the
|
|
7
|
+
[`claude-mcp-combiner`](https://github.com/georgeharker/claude-mcp-combiner) Claude Code plugin, and works standalone with any MCP client.
|
|
8
|
+
|
|
9
|
+
> PyPI package · command · import package: **`mcp-combiner`** / `mcp-combiner` / `mcp_combiner`.
|
|
10
|
+
|
|
11
|
+
> ⚠️ **Renamed from `mcp-bridge`.** If you ran an earlier build:
|
|
12
|
+
> - command/import are now `mcp-combiner` / `mcp_combiner`; reinstall:
|
|
13
|
+
> `uv tool uninstall mcp-bridge` then `uv tool install …` (see Install below).
|
|
14
|
+
> - config env vars `MCP_BRIDGE_*` → `MCP_COMBINER_*` (and `MCP_COMPANION_COMBINER_URL` →
|
|
15
|
+
> `MCP_COMPANION_COMBINER_URL`).
|
|
16
|
+
> - OAuth token storage moved to `~/.cache/mcp-combiner/` — you'll **re-authenticate each MCP
|
|
17
|
+
> server once** (old tokens under `~/.cache/mcp-companion/` are no longer read).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Needs only [uv](https://docs.astral.sh/uv/) — `uvx` fetches and runs it, no venv to manage:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uvx mcp-combiner --help # once published to PyPI
|
|
25
|
+
# before PyPI (or to track main) — the package lives in the combiner/ subdirectory:
|
|
26
|
+
uvx --from "git+https://github.com/georgeharker/mcp-companion#subdirectory=combiner" mcp-combiner
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install it: `uv pip install mcp-combiner` (PyPI), or from the repo subdir
|
|
30
|
+
`uv pip install "git+https://github.com/georgeharker/mcp-companion#subdirectory=combiner"`.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
mcp-combiner --config /path/to/servers.json --port 9741
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Development
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv sync
|
|
42
|
+
pytest
|
|
43
|
+
```
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""CLI entry point for mcp-combiner."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import atexit
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import types
|
|
11
|
+
|
|
12
|
+
import uvicorn
|
|
13
|
+
from starlette.applications import Starlette
|
|
14
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
15
|
+
from starlette.requests import Request as StarletteRequest
|
|
16
|
+
from starlette.responses import Response
|
|
17
|
+
|
|
18
|
+
from mcp_combiner.server import (
|
|
19
|
+
_pending_token_filters,
|
|
20
|
+
_token_sessions,
|
|
21
|
+
create_combiner,
|
|
22
|
+
)
|
|
23
|
+
from mcp_combiner.sharedserver import cleanup as cleanup_sharedservers
|
|
24
|
+
from mcp_combiner.sharedserver import register_for_cleanup
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_mcp_log = logging.getLogger("mcp-combiner.requests")
|
|
29
|
+
|
|
30
|
+
# Header name the Neovim plugin sets on ACP-injected mcpServers entries.
|
|
31
|
+
_ACP_TOKEN_HEADER = "x-mcp-combiner-session"
|
|
32
|
+
|
|
33
|
+
# UUID pattern: validates tokens from both header and URL path.
|
|
34
|
+
_TOKEN_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
35
|
+
|
|
36
|
+
# Match /mcp/<uuid>[/...] in the URL path.
|
|
37
|
+
_MCP_TOKEN_PATH_RE = re.compile(
|
|
38
|
+
r"^/mcp/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(/.*)?$"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TokenRewriteMiddleware(BaseHTTPMiddleware):
|
|
43
|
+
"""Map token -> MCP session-id and apply pending filters on connect.
|
|
44
|
+
|
|
45
|
+
Accepts the token from two sources:
|
|
46
|
+
1. URL path: /mcp/<token>[/...] — rewrites to /mcp so FastMCP sees a plain request.
|
|
47
|
+
2. HTTP header: X-MCP-Combiner-Session — fallback.
|
|
48
|
+
|
|
49
|
+
On first request carrying a token, records token->session_id from the response
|
|
50
|
+
header. If a pending filter was stored via POST /sessions/token/<token>/filter
|
|
51
|
+
before the client connected, it is applied immediately.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
async def dispatch(
|
|
55
|
+
self, request: StarletteRequest, call_next: RequestResponseEndpoint
|
|
56
|
+
) -> Response:
|
|
57
|
+
path = request.url.path
|
|
58
|
+
|
|
59
|
+
# --- Source 1: token in URL path ---
|
|
60
|
+
url_token: str | None = None
|
|
61
|
+
path_match = _MCP_TOKEN_PATH_RE.match(path)
|
|
62
|
+
if path_match:
|
|
63
|
+
url_token = path_match.group(1)
|
|
64
|
+
remainder = path_match.group(2) or ""
|
|
65
|
+
new_path = f"/mcp{remainder}"
|
|
66
|
+
logger.info(
|
|
67
|
+
"Token in URL path: token=%s %s -> %s",
|
|
68
|
+
url_token,
|
|
69
|
+
path,
|
|
70
|
+
new_path,
|
|
71
|
+
)
|
|
72
|
+
# Mutate scope in-place; BaseHTTPMiddleware passes the same scope dict
|
|
73
|
+
# to call_next so FastMCP receives the rewritten path.
|
|
74
|
+
request.scope["path"] = new_path
|
|
75
|
+
request.scope["raw_path"] = new_path.encode()
|
|
76
|
+
# Re-surface the URL token as a header so the FastMCP-layer
|
|
77
|
+
# middleware can build the session_id -> token reverse map (it only
|
|
78
|
+
# sees context.session_id, never the URL — see
|
|
79
|
+
# ToolProcessingMiddleware.on_request in server.py).
|
|
80
|
+
#
|
|
81
|
+
# WHY this is needed: header-sending clients (Claude Code, OpenCode,
|
|
82
|
+
# the documented ACP entry) already send X-MCP-Combiner-Session, so for
|
|
83
|
+
# them this is a redundant no-op. But URL-only transports — notably
|
|
84
|
+
# the stdio `mcp-remote` fallback, which forwards neither env nor
|
|
85
|
+
# headers, only the URL — would otherwise never get the token to the
|
|
86
|
+
# FastMCP layer, breaking neovim_* routing for that session. This
|
|
87
|
+
# injection makes /mcp/<token> a self-sufficient correlation channel.
|
|
88
|
+
# Replace any existing value so URL wins over a stale header.
|
|
89
|
+
hdr = _ACP_TOKEN_HEADER.encode()
|
|
90
|
+
headers = [(k, v) for (k, v) in request.scope["headers"] if k.lower() != hdr]
|
|
91
|
+
headers.append((hdr, url_token.encode()))
|
|
92
|
+
request.scope["headers"] = headers
|
|
93
|
+
|
|
94
|
+
# --- Source 2: token in header ---
|
|
95
|
+
header_token: str | None = request.headers.get(_ACP_TOKEN_HEADER)
|
|
96
|
+
if header_token and not _TOKEN_RE.match(header_token):
|
|
97
|
+
header_token = None
|
|
98
|
+
|
|
99
|
+
token = url_token or header_token
|
|
100
|
+
|
|
101
|
+
if token is None:
|
|
102
|
+
return await call_next(request)
|
|
103
|
+
|
|
104
|
+
already_mapped = token in _token_sessions
|
|
105
|
+
if not already_mapped:
|
|
106
|
+
logger.info(
|
|
107
|
+
"Token not yet mapped: token=%s source=%s method=%s",
|
|
108
|
+
token,
|
|
109
|
+
"url" if url_token else "header",
|
|
110
|
+
request.method,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
logger.debug(
|
|
114
|
+
"Token already mapped: token=%s session=%s",
|
|
115
|
+
token,
|
|
116
|
+
_token_sessions[token],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
response = await call_next(request)
|
|
120
|
+
|
|
121
|
+
if not already_mapped:
|
|
122
|
+
sid = response.headers.get("mcp-session-id")
|
|
123
|
+
if sid:
|
|
124
|
+
_token_sessions[token] = sid
|
|
125
|
+
logger.info(
|
|
126
|
+
"Token mapped: token=%s session=%s source=%s",
|
|
127
|
+
token,
|
|
128
|
+
sid,
|
|
129
|
+
"url" if url_token else "header",
|
|
130
|
+
)
|
|
131
|
+
# Apply any pending filter that was stored before the client connected
|
|
132
|
+
pending = _pending_token_filters.pop(token, None)
|
|
133
|
+
if pending:
|
|
134
|
+
from mcp_combiner.server import _session_disabled
|
|
135
|
+
|
|
136
|
+
_session_disabled[sid] = pending
|
|
137
|
+
logger.info(
|
|
138
|
+
"Pending token filter applied: token=%s session=%s disabled=%s",
|
|
139
|
+
token,
|
|
140
|
+
sid,
|
|
141
|
+
sorted(pending),
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
logger.debug(
|
|
145
|
+
"Token seen but no mcp-session-id in response: token=%s status=%d source=%s",
|
|
146
|
+
token,
|
|
147
|
+
response.status_code,
|
|
148
|
+
"url" if url_token else "header",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return response
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class MCPRequestLogMiddleware(BaseHTTPMiddleware):
|
|
155
|
+
"""Log /mcp requests: debug-level detail on every request, warnings on non-2xx."""
|
|
156
|
+
|
|
157
|
+
async def dispatch(
|
|
158
|
+
self, request: StarletteRequest, call_next: RequestResponseEndpoint
|
|
159
|
+
) -> Response:
|
|
160
|
+
path = request.url.path
|
|
161
|
+
is_mcp = path == "/mcp" or path.startswith("/mcp/")
|
|
162
|
+
if is_mcp and _mcp_log.isEnabledFor(logging.DEBUG):
|
|
163
|
+
session_id = request.headers.get("mcp-session-id", "-")
|
|
164
|
+
acp_token_hdr = request.headers.get(_ACP_TOKEN_HEADER, "-")
|
|
165
|
+
user_agent = request.headers.get("user-agent", "-")
|
|
166
|
+
accept = request.headers.get("accept", "-")
|
|
167
|
+
_mcp_log.debug(
|
|
168
|
+
"%s %s session=%s acp-token-hdr=%s ua=%s accept=%s all_headers=%s",
|
|
169
|
+
request.method,
|
|
170
|
+
path,
|
|
171
|
+
session_id,
|
|
172
|
+
acp_token_hdr,
|
|
173
|
+
user_agent,
|
|
174
|
+
accept,
|
|
175
|
+
dict(request.headers),
|
|
176
|
+
)
|
|
177
|
+
response = await call_next(request)
|
|
178
|
+
if is_mcp and response.status_code >= 400:
|
|
179
|
+
session_id = request.headers.get("mcp-session-id", "-")
|
|
180
|
+
user_agent = request.headers.get("user-agent", "-")
|
|
181
|
+
_mcp_log.warning(
|
|
182
|
+
"%s %s => %d session=%s ua=%s",
|
|
183
|
+
request.method,
|
|
184
|
+
path,
|
|
185
|
+
response.status_code,
|
|
186
|
+
session_id,
|
|
187
|
+
user_agent,
|
|
188
|
+
)
|
|
189
|
+
return response
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _signal_handler(signum: int, frame: types.FrameType | None) -> None:
|
|
193
|
+
"""Handle termination signals."""
|
|
194
|
+
logger.info("Received signal %d, cleaning up...", signum)
|
|
195
|
+
cleanup_sharedservers()
|
|
196
|
+
sys.exit(0)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def create_app() -> Starlette:
|
|
200
|
+
"""Factory function for creating the combiner ASGI app.
|
|
201
|
+
|
|
202
|
+
Reads config from environment variables set by main().
|
|
203
|
+
"""
|
|
204
|
+
config_path = os.environ["MCP_COMBINER_CONFIG"]
|
|
205
|
+
oauth_cache_str = os.environ.get("MCP_COMBINER_OAUTH_CACHE")
|
|
206
|
+
oauth_cache_tokens: bool | None = None
|
|
207
|
+
if oauth_cache_str == "True":
|
|
208
|
+
oauth_cache_tokens = True
|
|
209
|
+
elif oauth_cache_str == "False":
|
|
210
|
+
oauth_cache_tokens = False
|
|
211
|
+
oauth_token_dir = os.environ.get("MCP_COMBINER_OAUTH_TOKEN_DIR")
|
|
212
|
+
normalize_schemas = os.environ.get("MCP_COMBINER_NORMALIZE_SCHEMA") == "1"
|
|
213
|
+
|
|
214
|
+
def _tristate(name: str) -> bool | None:
|
|
215
|
+
"""Read a tri-state flag from env: '1' → True, '0' → False, unset → None."""
|
|
216
|
+
v = os.environ.get(name)
|
|
217
|
+
return None if v is None else v == "1"
|
|
218
|
+
|
|
219
|
+
input_validation = _tristate("MCP_COMBINER_INPUT_VALIDATION")
|
|
220
|
+
output_validation = _tristate("MCP_COMBINER_OUTPUT_VALIDATION")
|
|
221
|
+
|
|
222
|
+
combiner, ss_manager = create_combiner(
|
|
223
|
+
config_path,
|
|
224
|
+
oauth_cache_tokens=oauth_cache_tokens,
|
|
225
|
+
oauth_token_dir=oauth_token_dir,
|
|
226
|
+
normalize_schemas=normalize_schemas,
|
|
227
|
+
input_validation=input_validation,
|
|
228
|
+
output_validation=output_validation,
|
|
229
|
+
return_ss_manager=True,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Register manager for cleanup on exit
|
|
233
|
+
register_for_cleanup(ss_manager)
|
|
234
|
+
|
|
235
|
+
# Use streamable HTTP with stateful mode.
|
|
236
|
+
# Stateless mode doesn't support GET for SSE streams, which OpenCode needs.
|
|
237
|
+
app = combiner.http_app(
|
|
238
|
+
path="/mcp",
|
|
239
|
+
stateless_http=False,
|
|
240
|
+
)
|
|
241
|
+
app.add_middleware(MCPRequestLogMiddleware)
|
|
242
|
+
# TokenRewriteMiddleware is outermost (last-added in Starlette = outermost).
|
|
243
|
+
# It extracts the ACP token from /mcp/<token> URL paths and rewrites to /mcp
|
|
244
|
+
# before the log middleware and FastMCP see the request.
|
|
245
|
+
app.add_middleware(TokenRewriteMiddleware)
|
|
246
|
+
return app
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main() -> None:
|
|
250
|
+
parser = argparse.ArgumentParser(
|
|
251
|
+
prog="mcp-combiner",
|
|
252
|
+
description="MCP combiner — aggregates multiple MCP servers behind one endpoint",
|
|
253
|
+
)
|
|
254
|
+
parser.add_argument(
|
|
255
|
+
"--config",
|
|
256
|
+
required=True,
|
|
257
|
+
help="Path to servers.json config file",
|
|
258
|
+
)
|
|
259
|
+
parser.add_argument(
|
|
260
|
+
"--port",
|
|
261
|
+
type=int,
|
|
262
|
+
default=9741,
|
|
263
|
+
help="Port to listen on (default: 9741)",
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument(
|
|
266
|
+
"--host",
|
|
267
|
+
default="127.0.0.1",
|
|
268
|
+
help="Host to bind to (default: 127.0.0.1)",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# OAuth token-caching overrides (both override the config-file 'oauth' section)
|
|
272
|
+
oauth_group = parser.add_mutually_exclusive_group()
|
|
273
|
+
oauth_group.add_argument(
|
|
274
|
+
"--oauth-cache",
|
|
275
|
+
dest="oauth_cache",
|
|
276
|
+
action="store_true",
|
|
277
|
+
default=None,
|
|
278
|
+
help="Enable OAuth disk token caching (overrides config; this is the default)",
|
|
279
|
+
)
|
|
280
|
+
oauth_group.add_argument(
|
|
281
|
+
"--no-oauth-cache",
|
|
282
|
+
dest="oauth_cache",
|
|
283
|
+
action="store_false",
|
|
284
|
+
help=(
|
|
285
|
+
"Disable OAuth disk token caching — tokens kept in memory only "
|
|
286
|
+
"and lost on restart (overrides config)"
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
parser.add_argument(
|
|
290
|
+
"--oauth-token-dir",
|
|
291
|
+
metavar="PATH",
|
|
292
|
+
default=None,
|
|
293
|
+
help=(
|
|
294
|
+
"Directory for OAuth token files "
|
|
295
|
+
"(default: ~/.cache/mcp-combiner/oauth-tokens; overrides config)"
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
parser.add_argument(
|
|
299
|
+
"--normalize-schema",
|
|
300
|
+
dest="normalize_schema",
|
|
301
|
+
action="store_true",
|
|
302
|
+
default=False,
|
|
303
|
+
help=(
|
|
304
|
+
"Normalize tool JSON schemas to fix providers (e.g. moonshot-ai/kimi) "
|
|
305
|
+
"that reject schemas where 'type' and 'anyOf' coexist at the same level. "
|
|
306
|
+
"Applied to every tools/list response at cache-fill time."
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--input-validation",
|
|
311
|
+
dest="input_validation",
|
|
312
|
+
action=argparse.BooleanOptionalAction,
|
|
313
|
+
default=None,
|
|
314
|
+
help=(
|
|
315
|
+
"Tri-state JSON-schema validation of tool *input* arguments. "
|
|
316
|
+
"--input-validation forces it on; --no-input-validation forces it "
|
|
317
|
+
"off; omit to leave the combiner default (off — inputs are coerced, "
|
|
318
|
+
"not strictly validated)."
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
parser.add_argument(
|
|
322
|
+
"--output-validation",
|
|
323
|
+
dest="output_validation",
|
|
324
|
+
action=argparse.BooleanOptionalAction,
|
|
325
|
+
default=None,
|
|
326
|
+
help=(
|
|
327
|
+
"Tri-state JSON-schema validation of tool *output*. "
|
|
328
|
+
"--no-output-validation forces it off (the upstream server already "
|
|
329
|
+
"validated its structured output, so re-validating here is redundant "
|
|
330
|
+
"per-call work — measurably slow for large responses); "
|
|
331
|
+
"--output-validation forces it on; omit to leave the default (on for "
|
|
332
|
+
"tools that declare an outputSchema)."
|
|
333
|
+
),
|
|
334
|
+
)
|
|
335
|
+
parser.add_argument(
|
|
336
|
+
"--log-file",
|
|
337
|
+
metavar="PATH",
|
|
338
|
+
default=None,
|
|
339
|
+
help="Write logs to this file in addition to stderr (default: none)",
|
|
340
|
+
)
|
|
341
|
+
parser.add_argument(
|
|
342
|
+
"--log-level",
|
|
343
|
+
choices=["trace", "debug", "info", "warn", "error"],
|
|
344
|
+
default="info",
|
|
345
|
+
help=(
|
|
346
|
+
"Verbosity for the combiner logger and httpx/mcp-client loggers "
|
|
347
|
+
"(default: info). Use 'debug' to capture OAuth metadata-discovery, "
|
|
348
|
+
"token refresh, and httpx request/response detail."
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
args = parser.parse_args()
|
|
353
|
+
|
|
354
|
+
# Set env vars for app factory
|
|
355
|
+
os.environ["MCP_COMBINER_CONFIG"] = args.config
|
|
356
|
+
if args.oauth_cache is not None:
|
|
357
|
+
os.environ["MCP_COMBINER_OAUTH_CACHE"] = str(args.oauth_cache)
|
|
358
|
+
if args.oauth_token_dir:
|
|
359
|
+
os.environ["MCP_COMBINER_OAUTH_TOKEN_DIR"] = args.oauth_token_dir
|
|
360
|
+
if args.normalize_schema:
|
|
361
|
+
os.environ["MCP_COMBINER_NORMALIZE_SCHEMA"] = "1"
|
|
362
|
+
if args.input_validation is not None:
|
|
363
|
+
os.environ["MCP_COMBINER_INPUT_VALIDATION"] = "1" if args.input_validation else "0"
|
|
364
|
+
if args.output_validation is not None:
|
|
365
|
+
os.environ["MCP_COMBINER_OUTPUT_VALIDATION"] = "1" if args.output_validation else "0"
|
|
366
|
+
|
|
367
|
+
# Resolve --log-level to a stdlib logging numeric level.
|
|
368
|
+
# "trace" is treated as DEBUG since stdlib has no TRACE.
|
|
369
|
+
_level_map = {
|
|
370
|
+
"trace": logging.DEBUG,
|
|
371
|
+
"debug": logging.DEBUG,
|
|
372
|
+
"info": logging.INFO,
|
|
373
|
+
"warn": logging.WARNING,
|
|
374
|
+
"error": logging.ERROR,
|
|
375
|
+
}
|
|
376
|
+
level = _level_map[args.log_level]
|
|
377
|
+
|
|
378
|
+
# Stderr handler on the combiner logger. Without this only WARNING+ would
|
|
379
|
+
# appear because Python's root logger defaults to WARNING.
|
|
380
|
+
combiner_logger = logging.getLogger("mcp-combiner")
|
|
381
|
+
combiner_logger.setLevel(level)
|
|
382
|
+
if not combiner_logger.handlers:
|
|
383
|
+
stderr_handler = logging.StreamHandler()
|
|
384
|
+
stderr_handler.setLevel(level)
|
|
385
|
+
stderr_handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
|
|
386
|
+
combiner_logger.addHandler(stderr_handler)
|
|
387
|
+
combiner_logger.propagate = False # avoid duplicate messages via root
|
|
388
|
+
|
|
389
|
+
# Configure file logging if requested. File handler always runs at the
|
|
390
|
+
# requested level (decoupled from the file's presence so you can pick
|
|
391
|
+
# INFO+file or DEBUG+stderr-only independently).
|
|
392
|
+
if args.log_file:
|
|
393
|
+
import pathlib
|
|
394
|
+
|
|
395
|
+
log_path = pathlib.Path(args.log_file)
|
|
396
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
file_handler = logging.FileHandler(log_path)
|
|
398
|
+
file_handler.setLevel(level)
|
|
399
|
+
file_handler.setFormatter(
|
|
400
|
+
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
401
|
+
)
|
|
402
|
+
# Root catches non-combiner loggers (fastmcp, mcp.client.auth, httpx, …)
|
|
403
|
+
logging.getLogger().addHandler(file_handler)
|
|
404
|
+
logging.getLogger().setLevel(level)
|
|
405
|
+
# propagate=False on combiner_logger means the root handler won't see
|
|
406
|
+
# its messages — attach explicitly.
|
|
407
|
+
combiner_logger.addHandler(file_handler)
|
|
408
|
+
logger.info("Logging to %s at level %s", log_path, args.log_level)
|
|
409
|
+
else:
|
|
410
|
+
# No file — still apply level globally so DEBUG-on-stderr works.
|
|
411
|
+
logging.getLogger().setLevel(level)
|
|
412
|
+
|
|
413
|
+
# At DEBUG, also turn on the SDK loggers that carry the OAuth flow detail.
|
|
414
|
+
if level <= logging.DEBUG:
|
|
415
|
+
for name in ("httpx", "httpcore", "mcp.client.auth", "fastmcp.client.auth"):
|
|
416
|
+
logging.getLogger(name).setLevel(logging.DEBUG)
|
|
417
|
+
|
|
418
|
+
# Register cleanup handlers
|
|
419
|
+
atexit.register(cleanup_sharedservers)
|
|
420
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
421
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
422
|
+
|
|
423
|
+
# Single worker - async handles concurrency
|
|
424
|
+
app = create_app()
|
|
425
|
+
uvicorn.run(
|
|
426
|
+
app,
|
|
427
|
+
host=args.host,
|
|
428
|
+
port=args.port,
|
|
429
|
+
log_level="info",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
if __name__ == "__main__":
|
|
434
|
+
main()
|