authsome-mcp-proxy 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- authsome_mcp_proxy/__init__.py +9 -0
- authsome_mcp_proxy/__main__.py +554 -0
- authsome_mcp_proxy/config.py +252 -0
- authsome_mcp_proxy/external_oidc.py +548 -0
- authsome_mcp_proxy/inbound_auth.py +139 -0
- authsome_mcp_proxy/mcp_proxy.py +168 -0
- authsome_mcp_proxy/outbound_auth.py +220 -0
- authsome_mcp_proxy-0.4.0.dist-info/METADATA +750 -0
- authsome_mcp_proxy-0.4.0.dist-info/RECORD +12 -0
- authsome_mcp_proxy-0.4.0.dist-info/WHEEL +4 -0
- authsome_mcp_proxy-0.4.0.dist-info/entry_points.txt +2 -0
- authsome_mcp_proxy-0.4.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Authsome MCP proxy."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("authsome_mcp_proxy")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
# If the package is not installed, use a development version
|
|
9
|
+
__version__ = "0.0.0-dev"
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authsome MCP Proxy - Command-line interface.
|
|
3
|
+
|
|
4
|
+
This module provides the CLI entry point for running the MCP proxy server. It:
|
|
5
|
+
|
|
6
|
+
- Parses command-line arguments and environment variables
|
|
7
|
+
- Builds either a ``DesktopConfig`` (stdio transport) or a ``WebConfig``
|
|
8
|
+
(http transport) based on ``--transport``
|
|
9
|
+
- Launches the proxy server with appropriate settings
|
|
10
|
+
- Handles graceful shutdown and error reporting
|
|
11
|
+
|
|
12
|
+
CLI flags fall back to matching environment variables (CLI arguments take
|
|
13
|
+
precedence). For stdio mode the ``OIDC_*`` env vars apply; for http mode
|
|
14
|
+
the inbound provider params (``OIDC_*``, ``COGNITO_*``, ``AZURE_*``,
|
|
15
|
+
``--audience``, ``--proxy-base-url``) and the outbound mode params
|
|
16
|
+
(``OUTBOUND_*``) apply.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from exceptiongroup import BaseExceptionGroup
|
|
27
|
+
from mcp.shared.exceptions import McpError
|
|
28
|
+
|
|
29
|
+
from . import __version__, mcp_proxy
|
|
30
|
+
from .config import DesktopConfig, ProxyConfig, WebConfig
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_INBOUND_AUTH_PROVIDERS = ("oidc", "keycloak", "aws-cognito", "google", "azure")
|
|
36
|
+
_OUTBOUND_AUTH_MODES = ("forward", "oauth-client-credentials", "static")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cli() -> argparse.Namespace:
|
|
40
|
+
"""
|
|
41
|
+
Parse command line arguments and merge with environment variables.
|
|
42
|
+
|
|
43
|
+
Parses CLI arguments for OIDC configuration, upstream URL, and logging options.
|
|
44
|
+
Falls back to environment variables when CLI arguments are not provided, with
|
|
45
|
+
CLI arguments taking precedence.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Namespace: Parsed arguments with all configuration options.
|
|
49
|
+
"""
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
description=(
|
|
52
|
+
f"Authsome MCP Proxy -- bridges upstream MCP servers protected "
|
|
53
|
+
f"by token validation or static credentials to MCP clients such as "
|
|
54
|
+
f"Claude Desktop, Claude Code, Cursor, Codex, MCP Inspector, "
|
|
55
|
+
f"and Claude.ai (version {__version__})"
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Proxy server arguments
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"upstream_mcp_url",
|
|
62
|
+
metavar="UPSTREAM_MCP_URL",
|
|
63
|
+
nargs="?",
|
|
64
|
+
help="URL of the upstream MCP server to be proxied "
|
|
65
|
+
"(can also be set via UPSTREAM_MCP_URL env var)",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--no-banner",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="Don't show the proxy server banner",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Transport options
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--transport",
|
|
76
|
+
choices=["stdio", "http"],
|
|
77
|
+
default=None,
|
|
78
|
+
help="Transport to serve on. "
|
|
79
|
+
"'stdio' (default): proxy is launched as a local process by the MCP "
|
|
80
|
+
"client (Claude Desktop, Claude Code via 'claude mcp add --transport stdio', "
|
|
81
|
+
"Cursor, Codex, etc.). "
|
|
82
|
+
"'http': proxy runs as a standalone HTTP server that clients connect to by URL "
|
|
83
|
+
"(e.g. Claude Code via 'claude mcp add --transport http <name> <url>'). "
|
|
84
|
+
"Can also be set via MCP_PROXY_TRANSPORT env var.",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--host",
|
|
88
|
+
default=None,
|
|
89
|
+
help="Host address to bind to when using HTTP transport "
|
|
90
|
+
"(can also be set via MCP_PROXY_HOST env var, default: 0.0.0.0)",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--port",
|
|
94
|
+
type=int,
|
|
95
|
+
default=None,
|
|
96
|
+
help="Port to listen on when using HTTP transport "
|
|
97
|
+
"(can also be set via MCP_PROXY_PORT env var, default: 8000)",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Inbound OIDC options -- used by:
|
|
101
|
+
# - stdio: the desktop OAuth client (issuer/client/scopes/redirect)
|
|
102
|
+
# - http: the FastMCP inbound provider when --inbound-auth-provider is
|
|
103
|
+
# 'oidc'/'keycloak' (issuer_url), or as the OAuth client_id/secret
|
|
104
|
+
# common across 'oidc'/'aws-cognito'/'google'/'azure' (unused for
|
|
105
|
+
# 'keycloak' which is a RemoteAuthProvider).
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--oidc-issuer-url",
|
|
108
|
+
help="OIDC issuer / Keycloak realm URL "
|
|
109
|
+
"(can also be set via OIDC_ISSUER_URL env var)",
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--oidc-client-id",
|
|
113
|
+
help="OAuth client ID (can also be set via OIDC_CLIENT_ID env var)",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--oidc-client-secret",
|
|
117
|
+
help="OAuth client secret (can also be set via OIDC_CLIENT_SECRET env var, "
|
|
118
|
+
"optional for public OIDC clients that don't require any such)",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--oidc-scopes",
|
|
122
|
+
help="Space-separated OAuth scopes "
|
|
123
|
+
"(can also be set via OIDC_SCOPES env var, default for stdio: 'openid profile email')",
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--oidc-redirect-url",
|
|
127
|
+
help="Localhost URL for OAuth redirect -- stdio mode only "
|
|
128
|
+
"(can also be set via OIDC_REDIRECT_URL env var, "
|
|
129
|
+
"default: http://localhost:8080/auth/callback)",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Web-mode-only inbound options
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--inbound-auth-provider",
|
|
135
|
+
choices=list(_INBOUND_AUTH_PROVIDERS),
|
|
136
|
+
default=None,
|
|
137
|
+
help="Inbound auth provider for http mode. 'keycloak' uses FastMCP's "
|
|
138
|
+
"KeycloakAuthProvider (RemoteAuthProvider -- Keycloak >= 26.6.0 with "
|
|
139
|
+
"native DCR). 'oidc' uses OIDCProxy in DCR-bridge mode for any "
|
|
140
|
+
"generic OIDC IdP (including older Keycloak). 'aws-cognito', "
|
|
141
|
+
"'google', 'azure' use the matching IdP-specific FastMCP providers. "
|
|
142
|
+
"Can also be set via INBOUND_AUTH_PROVIDER env var.",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--proxy-base-url",
|
|
146
|
+
default=None,
|
|
147
|
+
help="Publicly reachable URL of the proxy -- required for http mode "
|
|
148
|
+
"(can also be set via MCP_PROXY_BASE_URL env var). Used by inbound providers to "
|
|
149
|
+
"advertise their authorization/token/JWKS endpoints to downstream MCP "
|
|
150
|
+
"clients via OAuth 2.0 protected-resource metadata.",
|
|
151
|
+
)
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--audience",
|
|
154
|
+
default=None,
|
|
155
|
+
help="JWT 'aud' claim to require on incoming tokens -- used by oidc and "
|
|
156
|
+
"keycloak inbound providers "
|
|
157
|
+
"(can also be set via AUDIENCE env var, recommended for production)",
|
|
158
|
+
)
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
"--cognito-user-pool-id",
|
|
161
|
+
default=None,
|
|
162
|
+
help="AWS Cognito user pool ID -- required for --inbound-auth-provider aws-cognito "
|
|
163
|
+
"(can also be set via COGNITO_USER_POOL_ID env var)",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"--cognito-aws-region",
|
|
167
|
+
default=None,
|
|
168
|
+
help="AWS region for the Cognito user pool -- required for --inbound-auth-provider aws-cognito "
|
|
169
|
+
"(can also be set via COGNITO_AWS_REGION env var)",
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--azure-tenant-id",
|
|
173
|
+
default=None,
|
|
174
|
+
help="Azure AD tenant ID -- required for --inbound-auth-provider azure "
|
|
175
|
+
"(can also be set via AZURE_TENANT_ID env var)",
|
|
176
|
+
)
|
|
177
|
+
parser.add_argument(
|
|
178
|
+
"--azure-identifier-uri",
|
|
179
|
+
default=None,
|
|
180
|
+
help="Azure Application ID URI used for scope prefixing -- optional for "
|
|
181
|
+
"--inbound-auth-provider azure (can also be set via AZURE_IDENTIFIER_URI env var)",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Web-mode-only outbound options
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--outbound-auth",
|
|
187
|
+
choices=list(_OUTBOUND_AUTH_MODES),
|
|
188
|
+
default=None,
|
|
189
|
+
help="How the proxy authenticates outbound calls to the upstream MCP "
|
|
190
|
+
"server. 'forward' (default): reuse the downstream session's bearer "
|
|
191
|
+
"token (Pattern C). 'oauth-client-credentials': proxy obtains its own "
|
|
192
|
+
"token via OAuth client_credentials grant against an outbound IdP "
|
|
193
|
+
"independent of the inbound IdP (Pattern B with a tenant- or user-"
|
|
194
|
+
"scoped OAuth credential). 'static': proxy injects a fixed header "
|
|
195
|
+
"value such as an API key, API token, or PAT (Pattern B with a "
|
|
196
|
+
"tenant- or user-scoped static credential). "
|
|
197
|
+
"Can also be set via OUTBOUND_AUTH env var.",
|
|
198
|
+
)
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"--outbound-client-id",
|
|
201
|
+
default=None,
|
|
202
|
+
help="OAuth client ID for outbound oauth-client-credentials mode "
|
|
203
|
+
"(can also be set via OUTBOUND_CLIENT_ID env var)",
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument(
|
|
206
|
+
"--outbound-client-secret",
|
|
207
|
+
default=None,
|
|
208
|
+
help="OAuth client secret for outbound oauth-client-credentials mode "
|
|
209
|
+
"(can also be set via OUTBOUND_CLIENT_SECRET env var)",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--outbound-token-url",
|
|
213
|
+
default=None,
|
|
214
|
+
help="Token endpoint URL for outbound oauth-client-credentials mode "
|
|
215
|
+
"(can also be set via OUTBOUND_TOKEN_URL env var; independent of the "
|
|
216
|
+
"inbound IdP -- in Pattern B the upstream's auth mechanism is by "
|
|
217
|
+
"definition disconnected from the IdP that fronts the proxy)",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"--outbound-header-name",
|
|
221
|
+
default=None,
|
|
222
|
+
help="Header name for outbound static mode "
|
|
223
|
+
"(can also be set via OUTBOUND_HEADER_NAME env var, default: Authorization)",
|
|
224
|
+
)
|
|
225
|
+
parser.add_argument(
|
|
226
|
+
"--outbound-header-value",
|
|
227
|
+
default=None,
|
|
228
|
+
help="Literal header value for outbound static mode "
|
|
229
|
+
"(can also be set via OUTBOUND_HEADER_VALUE env var; e.g. 'Bearer eyJ...' "
|
|
230
|
+
"for a bearer token, or a bare API key paired with "
|
|
231
|
+
"--outbound-header-name X-API-Key)",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Web-mode-only server-identity options
|
|
235
|
+
parser.add_argument(
|
|
236
|
+
"--proxy-name",
|
|
237
|
+
default=None,
|
|
238
|
+
help="Display name advertised to downstream MCP clients (e.g. 'ANALYZE'). "
|
|
239
|
+
"Without this, FastMCP auto-generates 'FastMCPProxy-xxxx'. "
|
|
240
|
+
"Can also be set via MCP_PROXY_NAME env var.",
|
|
241
|
+
)
|
|
242
|
+
parser.add_argument(
|
|
243
|
+
"--proxy-version",
|
|
244
|
+
default=None,
|
|
245
|
+
help="Display version string advertised to downstream MCP clients "
|
|
246
|
+
"(can also be set via MCP_PROXY_VERSION env var)",
|
|
247
|
+
)
|
|
248
|
+
parser.add_argument(
|
|
249
|
+
"--proxy-instructions",
|
|
250
|
+
default=None,
|
|
251
|
+
help="Instructions advertised alongside the tool catalog -- influences "
|
|
252
|
+
"how the LLM selects and uses the upstream's tools "
|
|
253
|
+
"(can also be set via MCP_PROXY_INSTRUCTIONS env var)",
|
|
254
|
+
)
|
|
255
|
+
parser.add_argument(
|
|
256
|
+
"--proxy-website-url",
|
|
257
|
+
default=None,
|
|
258
|
+
help="Project URL shown in downstream MCP client UIs "
|
|
259
|
+
"(can also be set via MCP_PROXY_WEBSITE_URL env var)",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Logging options
|
|
263
|
+
group = parser.add_mutually_exclusive_group()
|
|
264
|
+
group.add_argument("--silent", action="store_true", help="Show only error messages")
|
|
265
|
+
group.add_argument(
|
|
266
|
+
"--debug",
|
|
267
|
+
action="store_true",
|
|
268
|
+
help="Enable debug logging (can also be set through 'MCP_PROXY_DEBUG' environment variable)",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
args = parser.parse_args()
|
|
272
|
+
|
|
273
|
+
# Env var fallbacks (CLI args win when present)
|
|
274
|
+
_apply_env_fallbacks(args)
|
|
275
|
+
|
|
276
|
+
return args
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _apply_env_fallbacks(args: argparse.Namespace) -> None:
|
|
280
|
+
"""Apply environment-variable fallbacks for any CLI args left unset.
|
|
281
|
+
|
|
282
|
+
Walks a table of (attr, env_var) pairs and sets each unset attribute
|
|
283
|
+
from its matching env var. Mode-specific defaulting (default transport,
|
|
284
|
+
int-conversion for port, boolean parsing for MCP_PROXY_DEBUG) is
|
|
285
|
+
handled separately below.
|
|
286
|
+
"""
|
|
287
|
+
env_fallbacks = (
|
|
288
|
+
("upstream_mcp_url", "UPSTREAM_MCP_URL"),
|
|
289
|
+
("host", "MCP_PROXY_HOST"),
|
|
290
|
+
("oidc_issuer_url", "OIDC_ISSUER_URL"),
|
|
291
|
+
("oidc_client_id", "OIDC_CLIENT_ID"),
|
|
292
|
+
("oidc_client_secret", "OIDC_CLIENT_SECRET"),
|
|
293
|
+
("oidc_scopes", "OIDC_SCOPES"),
|
|
294
|
+
("oidc_redirect_url", "OIDC_REDIRECT_URL"),
|
|
295
|
+
("inbound_auth_provider", "INBOUND_AUTH_PROVIDER"),
|
|
296
|
+
("proxy_base_url", "MCP_PROXY_BASE_URL"),
|
|
297
|
+
("audience", "AUDIENCE"),
|
|
298
|
+
("cognito_user_pool_id", "COGNITO_USER_POOL_ID"),
|
|
299
|
+
("cognito_aws_region", "COGNITO_AWS_REGION"),
|
|
300
|
+
("azure_tenant_id", "AZURE_TENANT_ID"),
|
|
301
|
+
("azure_identifier_uri", "AZURE_IDENTIFIER_URI"),
|
|
302
|
+
("outbound_auth", "OUTBOUND_AUTH"),
|
|
303
|
+
("outbound_client_id", "OUTBOUND_CLIENT_ID"),
|
|
304
|
+
("outbound_client_secret", "OUTBOUND_CLIENT_SECRET"),
|
|
305
|
+
("outbound_token_url", "OUTBOUND_TOKEN_URL"),
|
|
306
|
+
("outbound_header_name", "OUTBOUND_HEADER_NAME"),
|
|
307
|
+
("outbound_header_value", "OUTBOUND_HEADER_VALUE"),
|
|
308
|
+
("proxy_name", "MCP_PROXY_NAME"),
|
|
309
|
+
("proxy_version", "MCP_PROXY_VERSION"),
|
|
310
|
+
("proxy_instructions", "MCP_PROXY_INSTRUCTIONS"),
|
|
311
|
+
("proxy_website_url", "MCP_PROXY_WEBSITE_URL"),
|
|
312
|
+
)
|
|
313
|
+
for attr, env_name in env_fallbacks:
|
|
314
|
+
if not getattr(args, attr):
|
|
315
|
+
setattr(args, attr, os.getenv(env_name))
|
|
316
|
+
|
|
317
|
+
if not args.transport:
|
|
318
|
+
args.transport = os.getenv("MCP_PROXY_TRANSPORT") or "stdio"
|
|
319
|
+
if not args.port:
|
|
320
|
+
port_env = os.getenv("MCP_PROXY_PORT")
|
|
321
|
+
args.port = int(port_env) if port_env else None
|
|
322
|
+
if not args.debug:
|
|
323
|
+
args.debug = os.getenv("MCP_PROXY_DEBUG", "").lower() in (
|
|
324
|
+
"1",
|
|
325
|
+
"true",
|
|
326
|
+
"yes",
|
|
327
|
+
"on",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def build_proxy_config(args: argparse.Namespace) -> ProxyConfig:
|
|
332
|
+
"""Build the appropriate ProxyConfig from parsed CLI args.
|
|
333
|
+
|
|
334
|
+
Branches on ``args.transport``:
|
|
335
|
+
|
|
336
|
+
- ``stdio`` -> ``DesktopConfig`` using the ``--oidc-*`` flags.
|
|
337
|
+
- ``http`` -> ``WebConfig`` using ``--inbound-auth-provider`` plus the relevant
|
|
338
|
+
per-provider and outbound flags. Per-provider required-field
|
|
339
|
+
validation lives in ``WebConfig.__post_init__``; missing transport-
|
|
340
|
+
level fields (e.g. ``--proxy-base-url``) are caught here.
|
|
341
|
+
|
|
342
|
+
Raises:
|
|
343
|
+
ValueError: If required transport-level fields are missing.
|
|
344
|
+
"""
|
|
345
|
+
if args.transport == "stdio":
|
|
346
|
+
return DesktopConfig(
|
|
347
|
+
issuer_url=args.oidc_issuer_url,
|
|
348
|
+
client_id=args.oidc_client_id,
|
|
349
|
+
client_secret=args.oidc_client_secret,
|
|
350
|
+
scopes=args.oidc_scopes,
|
|
351
|
+
redirect_url=args.oidc_redirect_url,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# http
|
|
355
|
+
if not args.proxy_base_url:
|
|
356
|
+
raise ValueError(
|
|
357
|
+
"--proxy-base-url (or MCP_PROXY_BASE_URL env var) is required for --transport http"
|
|
358
|
+
)
|
|
359
|
+
if not args.inbound_auth_provider:
|
|
360
|
+
raise ValueError(
|
|
361
|
+
"--inbound-auth-provider (or INBOUND_AUTH_PROVIDER env var) is required for "
|
|
362
|
+
"--transport http"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return WebConfig(
|
|
366
|
+
inbound_auth_provider=args.inbound_auth_provider,
|
|
367
|
+
proxy_base_url=args.proxy_base_url,
|
|
368
|
+
client_id=args.oidc_client_id,
|
|
369
|
+
client_secret=args.oidc_client_secret,
|
|
370
|
+
scopes=args.oidc_scopes,
|
|
371
|
+
issuer_url=args.oidc_issuer_url,
|
|
372
|
+
audience=args.audience,
|
|
373
|
+
cognito_user_pool_id=args.cognito_user_pool_id,
|
|
374
|
+
cognito_aws_region=args.cognito_aws_region,
|
|
375
|
+
azure_tenant_id=args.azure_tenant_id,
|
|
376
|
+
azure_identifier_uri=args.azure_identifier_uri,
|
|
377
|
+
outbound_auth=args.outbound_auth or "forward",
|
|
378
|
+
outbound_client_id=args.outbound_client_id,
|
|
379
|
+
outbound_client_secret=args.outbound_client_secret,
|
|
380
|
+
outbound_token_url=args.outbound_token_url,
|
|
381
|
+
outbound_header_name=args.outbound_header_name or "Authorization",
|
|
382
|
+
outbound_header_value=args.outbound_header_value,
|
|
383
|
+
proxy_name=args.proxy_name,
|
|
384
|
+
proxy_version=args.proxy_version,
|
|
385
|
+
proxy_instructions=args.proxy_instructions,
|
|
386
|
+
proxy_website_url=args.proxy_website_url,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class _LowercaseLevelFormatter(logging.Formatter):
|
|
391
|
+
"""Formatter that lowercases the level name to match Claude Desktop log style."""
|
|
392
|
+
|
|
393
|
+
def format(self, record):
|
|
394
|
+
record.levelname = record.levelname.lower()
|
|
395
|
+
return super().format(record)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def configure_logging(args):
|
|
399
|
+
"""Configure logging based on command line arguments.
|
|
400
|
+
|
|
401
|
+
In stdio mode, stdout is reserved for MCP JSON-RPC traffic so logs are
|
|
402
|
+
forced to stderr. In http mode, stdout is free; logs go to stdout there
|
|
403
|
+
(more conventional for HTTP server output, especially in containers).
|
|
404
|
+
"""
|
|
405
|
+
if args.silent:
|
|
406
|
+
log_level = logging.ERROR
|
|
407
|
+
elif args.debug:
|
|
408
|
+
log_level = logging.DEBUG
|
|
409
|
+
else:
|
|
410
|
+
log_level = logging.INFO
|
|
411
|
+
|
|
412
|
+
stream = sys.stderr if args.transport == "stdio" else sys.stdout
|
|
413
|
+
handler = logging.StreamHandler(stream)
|
|
414
|
+
handler.setFormatter(
|
|
415
|
+
_LowercaseLevelFormatter(
|
|
416
|
+
fmt="%(asctime)s.%(msecs)03dZ [%(name)s] [%(levelname)s] %(message)s",
|
|
417
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
logging.root.addHandler(handler)
|
|
421
|
+
logging.root.setLevel(log_level)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def get_log_level_name(args) -> str:
|
|
425
|
+
"""
|
|
426
|
+
Determine the appropriate log level based on command line arguments.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
args: Parsed command line arguments containing silent/debug flags.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
str: Log level name ('ERROR', 'DEBUG', or 'INFO').
|
|
433
|
+
"""
|
|
434
|
+
if args.silent:
|
|
435
|
+
return logging.getLevelName(logging.ERROR)
|
|
436
|
+
elif args.debug:
|
|
437
|
+
return logging.getLevelName(logging.DEBUG)
|
|
438
|
+
else:
|
|
439
|
+
return logging.getLevelName(logging.INFO)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def extract_root_cause(eg: BaseExceptionGroup) -> BaseException:
|
|
443
|
+
"""Extract the root cause from singly-nested exception groups.
|
|
444
|
+
|
|
445
|
+
Exceptions from anyio task groups and asyncio often get wrapped in multiple
|
|
446
|
+
layers of BaseExceptionGroup. This recursively unwraps to find the actual cause.
|
|
447
|
+
"""
|
|
448
|
+
exceptions = eg.exceptions
|
|
449
|
+
while len(exceptions) == 1 and isinstance(exceptions[0], BaseExceptionGroup):
|
|
450
|
+
exceptions = exceptions[0].exceptions
|
|
451
|
+
if len(exceptions) == 1:
|
|
452
|
+
return exceptions[0]
|
|
453
|
+
return eg
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def log_error_and_exit(exc: BaseException) -> None:
|
|
457
|
+
"""Log an exception appropriately and exit with status 1.
|
|
458
|
+
|
|
459
|
+
Provides clean error messages without stack traces for expected error types,
|
|
460
|
+
and full tracebacks for unexpected internal errors. Recursively handles
|
|
461
|
+
BaseExceptionGroup by extracting and processing the root cause.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
exc: The exception to log and handle.
|
|
465
|
+
"""
|
|
466
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
467
|
+
# Graceful shutdown - exit without logging
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
# Handle BaseExceptionGroup recursively
|
|
471
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
472
|
+
cause = extract_root_cause(exc)
|
|
473
|
+
if isinstance(cause, SystemExit):
|
|
474
|
+
# SystemExit from uvicorn loses the original error message;
|
|
475
|
+
# check __context__ for the real cause (e.g., OSError from port binding)
|
|
476
|
+
context = getattr(cause, "__context__", None)
|
|
477
|
+
log_error_and_exit(context if context else cause)
|
|
478
|
+
else:
|
|
479
|
+
log_error_and_exit(cause)
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
# Log based on exception type
|
|
483
|
+
if isinstance(exc, httpx.HTTPStatusError | McpError):
|
|
484
|
+
logger.error(f"Backend error: {exc}")
|
|
485
|
+
elif isinstance(
|
|
486
|
+
exc,
|
|
487
|
+
httpx.ConnectError
|
|
488
|
+
| httpx.ConnectTimeout
|
|
489
|
+
| httpx.ReadTimeout
|
|
490
|
+
| httpx.TimeoutException,
|
|
491
|
+
):
|
|
492
|
+
logger.error(f"Network error: {exc}")
|
|
493
|
+
elif isinstance(exc, OSError):
|
|
494
|
+
logger.error(f"System error: {exc}")
|
|
495
|
+
elif isinstance(exc, ValueError):
|
|
496
|
+
logger.error(f"Configuration error: {exc}")
|
|
497
|
+
elif isinstance(exc, RuntimeError):
|
|
498
|
+
logger.error(f"Runtime error: {exc}")
|
|
499
|
+
elif isinstance(exc, SystemExit):
|
|
500
|
+
# Unexpected system exit without proper context
|
|
501
|
+
logger.error(f"Unexpected system exit: {exc}")
|
|
502
|
+
sys.exit(exc.code if exc.code is not None else 1)
|
|
503
|
+
else:
|
|
504
|
+
# Unexpected internal error - include full traceback for debugging
|
|
505
|
+
logger.error(f"Internal error: {exc}", exc_info=exc)
|
|
506
|
+
|
|
507
|
+
sys.exit(1)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def main():
|
|
511
|
+
"""
|
|
512
|
+
Main entry point for the Authsome MCP Proxy application.
|
|
513
|
+
|
|
514
|
+
Parses configuration, builds the appropriate ProxyConfig (DesktopConfig for
|
|
515
|
+
stdio mode, WebConfig for http mode), and launches the proxy server. Handles
|
|
516
|
+
graceful shutdown and provides appropriate error messages for different
|
|
517
|
+
exception types.
|
|
518
|
+
|
|
519
|
+
Exits with status code 1 on errors, 0 on successful completion.
|
|
520
|
+
"""
|
|
521
|
+
args = cli()
|
|
522
|
+
configure_logging(args)
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
config = build_proxy_config(args)
|
|
526
|
+
|
|
527
|
+
# Build transport kwargs forwarded to FastMCP's run_async.
|
|
528
|
+
# log_level applies to both transports; host/port apply to http only.
|
|
529
|
+
transport_kwargs: dict = {"log_level": get_log_level_name(args)}
|
|
530
|
+
if args.transport == "http":
|
|
531
|
+
if args.host:
|
|
532
|
+
transport_kwargs["host"] = args.host
|
|
533
|
+
if args.port:
|
|
534
|
+
transport_kwargs["port"] = args.port
|
|
535
|
+
|
|
536
|
+
# Start the MCP proxy
|
|
537
|
+
asyncio.run(
|
|
538
|
+
mcp_proxy.run_async(
|
|
539
|
+
upstream_url=args.upstream_mcp_url,
|
|
540
|
+
config=config,
|
|
541
|
+
show_banner=not args.no_banner,
|
|
542
|
+
**transport_kwargs,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
except Exception as e:
|
|
546
|
+
# Catch-all for any exceptions (BaseExceptionGroup, KeyboardInterrupt, etc.)
|
|
547
|
+
# All exception handling logic is in log_error_and_exit
|
|
548
|
+
log_error_and_exit(e)
|
|
549
|
+
finally:
|
|
550
|
+
logging.shutdown()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
if __name__ == "__main__":
|
|
554
|
+
main()
|