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.
@@ -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,3 @@
1
+ """mcp-combiner — a FastMCP MCP aggregator: fronts multiple MCP servers behind one endpoint."""
2
+
3
+ __version__ = "0.6.0"
@@ -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()