mcp-proxy-oauth-dcr 0.1.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.
- mcp_proxy/__init__.py +89 -0
- mcp_proxy/__main__.py +340 -0
- mcp_proxy/auth/__init__.py +8 -0
- mcp_proxy/auth/manager.py +908 -0
- mcp_proxy/config/__init__.py +8 -0
- mcp_proxy/config/manager.py +200 -0
- mcp_proxy/exceptions.py +186 -0
- mcp_proxy/http/__init__.py +9 -0
- mcp_proxy/http/authenticated_client.py +388 -0
- mcp_proxy/http/client.py +997 -0
- mcp_proxy/logging_config.py +71 -0
- mcp_proxy/models.py +259 -0
- mcp_proxy/protocols.py +122 -0
- mcp_proxy/proxy.py +586 -0
- mcp_proxy/stdio/__init__.py +31 -0
- mcp_proxy/stdio/interface.py +580 -0
- mcp_proxy/stdio/jsonrpc.py +371 -0
- mcp_proxy/translator/__init__.py +11 -0
- mcp_proxy/translator/translator.py +691 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/METADATA +167 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/RECORD +25 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/WHEEL +5 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Authenticated HTTP client that integrates OAuth authentication with HTTP requests.
|
|
2
|
+
|
|
3
|
+
This module provides an HTTP client wrapper that:
|
|
4
|
+
- Automatically attaches Bearer tokens to all HTTP MCP requests
|
|
5
|
+
- Handles 401 Unauthorized responses with automatic token refresh
|
|
6
|
+
- Implements fallback to DCR when token refresh fails
|
|
7
|
+
- Provides transparent authentication for all HTTP operations
|
|
8
|
+
- Comprehensive error logging and diagnostics
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional, AsyncIterator
|
|
13
|
+
|
|
14
|
+
from ..auth.manager import AuthenticationManagerImpl
|
|
15
|
+
from ..models import HttpMcpRequest, HttpMcpResponse
|
|
16
|
+
from ..exceptions import (
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
TokenError,
|
|
19
|
+
HttpError,
|
|
20
|
+
)
|
|
21
|
+
from .client import HttpClientImpl, SSEEvent
|
|
22
|
+
from ..logging_config import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticatedHttpClient:
|
|
28
|
+
"""HTTP client with integrated OAuth authentication.
|
|
29
|
+
|
|
30
|
+
This client wraps HttpClientImpl and AuthenticationManagerImpl to provide
|
|
31
|
+
transparent authentication for all HTTP requests. It automatically:
|
|
32
|
+
- Attaches valid Bearer tokens to requests
|
|
33
|
+
- Refreshes tokens when they expire
|
|
34
|
+
- Handles 401 responses by refreshing tokens and retrying
|
|
35
|
+
- Falls back to DCR if token refresh fails
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
http_client: Underlying HTTP client for making requests
|
|
39
|
+
auth_manager: Authentication manager for token management
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
http_client: HttpClientImpl,
|
|
45
|
+
auth_manager: AuthenticationManagerImpl,
|
|
46
|
+
):
|
|
47
|
+
"""Initialize the authenticated HTTP client.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
http_client: HTTP client instance for making requests
|
|
51
|
+
auth_manager: Authentication manager for token management
|
|
52
|
+
"""
|
|
53
|
+
self._http_client = http_client
|
|
54
|
+
self._auth_manager = auth_manager
|
|
55
|
+
self._max_auth_retries = 2 # Maximum retries for 401 errors
|
|
56
|
+
|
|
57
|
+
logger.debug("AuthenticatedHttpClient initialized")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def http_client(self) -> HttpClientImpl:
|
|
61
|
+
"""Get the underlying HTTP client."""
|
|
62
|
+
return self._http_client
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def auth_manager(self) -> AuthenticationManagerImpl:
|
|
66
|
+
"""Get the authentication manager."""
|
|
67
|
+
return self._auth_manager
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def session_id(self) -> Optional[str]:
|
|
71
|
+
"""Get the current session ID from HTTP client."""
|
|
72
|
+
return self._http_client.session_id
|
|
73
|
+
|
|
74
|
+
@session_id.setter
|
|
75
|
+
def session_id(self, value: Optional[str]) -> None:
|
|
76
|
+
"""Set the session ID on HTTP client."""
|
|
77
|
+
self._http_client.session_id = value
|
|
78
|
+
|
|
79
|
+
async def initialize(self) -> None:
|
|
80
|
+
"""Initialize the authenticated client.
|
|
81
|
+
|
|
82
|
+
This method:
|
|
83
|
+
1. Initializes the authentication manager (performs DCR if needed)
|
|
84
|
+
2. Obtains an initial access token
|
|
85
|
+
3. Sets the token on the HTTP client
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
AuthenticationError: If initialization fails
|
|
89
|
+
"""
|
|
90
|
+
logger.info("Initializing authenticated HTTP client")
|
|
91
|
+
|
|
92
|
+
# Initialize authentication manager (performs DCR and gets token)
|
|
93
|
+
await self._auth_manager.initialize()
|
|
94
|
+
|
|
95
|
+
# Set initial token on HTTP client
|
|
96
|
+
await self._update_http_client_token()
|
|
97
|
+
|
|
98
|
+
logger.info("Authenticated HTTP client initialized successfully")
|
|
99
|
+
|
|
100
|
+
async def _update_http_client_token(self) -> None:
|
|
101
|
+
"""Update the HTTP client with the current access token.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
TokenError: If unable to obtain a valid token
|
|
105
|
+
"""
|
|
106
|
+
token = await self._auth_manager.get_access_token()
|
|
107
|
+
self._http_client.set_auth_token(token)
|
|
108
|
+
logger.debug("Updated HTTP client with fresh access token")
|
|
109
|
+
|
|
110
|
+
async def send_request(self, request: HttpMcpRequest) -> HttpMcpResponse:
|
|
111
|
+
"""Send an authenticated HTTP request with automatic token refresh.
|
|
112
|
+
|
|
113
|
+
This method:
|
|
114
|
+
1. Ensures a valid token is attached to the request
|
|
115
|
+
2. Sends the request via the HTTP client
|
|
116
|
+
3. Handles 401 responses by refreshing the token and retrying
|
|
117
|
+
4. Falls back to DCR if token refresh fails
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
request: HTTP MCP request to send
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
HTTP MCP response
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
AuthenticationError: If authentication fails after all retries
|
|
127
|
+
HttpError: If HTTP error occurs (non-401)
|
|
128
|
+
ConnectionError: If connection fails
|
|
129
|
+
"""
|
|
130
|
+
# Ensure we have a valid token before sending
|
|
131
|
+
await self._update_http_client_token()
|
|
132
|
+
|
|
133
|
+
# Try sending the request with automatic 401 handling
|
|
134
|
+
for attempt in range(self._max_auth_retries + 1):
|
|
135
|
+
try:
|
|
136
|
+
response = await self._http_client.send_request(request)
|
|
137
|
+
|
|
138
|
+
# Check for 401 Unauthorized
|
|
139
|
+
if response.is_auth_error():
|
|
140
|
+
logger.warning(
|
|
141
|
+
"Received 401 Unauthorized response",
|
|
142
|
+
attempt=attempt + 1,
|
|
143
|
+
max_attempts=self._max_auth_retries + 1,
|
|
144
|
+
url=request.url,
|
|
145
|
+
method=request.method,
|
|
146
|
+
session_id=self._http_client.session_id,
|
|
147
|
+
response_body=response.body[:500] if response.body else None, # Log response for debugging
|
|
148
|
+
response_headers=response.headers,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# If this is not the last attempt, try to refresh and retry
|
|
152
|
+
if attempt < self._max_auth_retries:
|
|
153
|
+
await self._handle_auth_error(attempt)
|
|
154
|
+
continue
|
|
155
|
+
else:
|
|
156
|
+
# Last attempt failed, raise error with diagnostics
|
|
157
|
+
auth_diagnostics = self._auth_manager.get_diagnostic_info()
|
|
158
|
+
logger.error(
|
|
159
|
+
"Authentication failed after all retry attempts",
|
|
160
|
+
attempts=attempt + 1,
|
|
161
|
+
url=request.url,
|
|
162
|
+
method=request.method,
|
|
163
|
+
auth_diagnostics=auth_diagnostics,
|
|
164
|
+
)
|
|
165
|
+
raise AuthenticationError(
|
|
166
|
+
"Authentication failed after token refresh attempts",
|
|
167
|
+
details={
|
|
168
|
+
"status": 401,
|
|
169
|
+
"body": response.body,
|
|
170
|
+
"diagnostics": auth_diagnostics,
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Success - return response
|
|
175
|
+
logger.debug(
|
|
176
|
+
"Request completed successfully",
|
|
177
|
+
url=request.url,
|
|
178
|
+
method=request.method,
|
|
179
|
+
status=response.status,
|
|
180
|
+
)
|
|
181
|
+
return response
|
|
182
|
+
|
|
183
|
+
except HttpError as e:
|
|
184
|
+
# If it's a 401 error from the HTTP client, handle it
|
|
185
|
+
if e.status_code == 401 and attempt < self._max_auth_retries:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"HTTP client raised 401 error",
|
|
188
|
+
attempt=attempt + 1,
|
|
189
|
+
max_attempts=self._max_auth_retries + 1,
|
|
190
|
+
url=request.url,
|
|
191
|
+
method=request.method,
|
|
192
|
+
)
|
|
193
|
+
await self._handle_auth_error(attempt)
|
|
194
|
+
continue
|
|
195
|
+
else:
|
|
196
|
+
# Not a 401 or last attempt, re-raise
|
|
197
|
+
logger.error(
|
|
198
|
+
"HTTP error during request",
|
|
199
|
+
status_code=e.status_code,
|
|
200
|
+
error=str(e),
|
|
201
|
+
url=request.url,
|
|
202
|
+
method=request.method,
|
|
203
|
+
)
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
# Should not reach here, but just in case
|
|
207
|
+
raise AuthenticationError("Authentication failed with unknown error")
|
|
208
|
+
|
|
209
|
+
async def _handle_auth_error(self, attempt: int) -> None:
|
|
210
|
+
"""Handle 401 authentication error by refreshing token.
|
|
211
|
+
|
|
212
|
+
This method implements the authentication recovery strategy:
|
|
213
|
+
1. First attempt: Try to refresh the token
|
|
214
|
+
2. Second attempt: Fall back to DCR if refresh fails
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
attempt: Current attempt number (0-indexed)
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
AuthenticationError: If unable to recover from auth error
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
if attempt == 0:
|
|
224
|
+
# First 401: Try token refresh
|
|
225
|
+
logger.info(
|
|
226
|
+
"Attempting token refresh after 401 response",
|
|
227
|
+
attempt=attempt + 1,
|
|
228
|
+
)
|
|
229
|
+
new_token = await self._auth_manager.refresh_token()
|
|
230
|
+
self._http_client.set_auth_token(new_token)
|
|
231
|
+
logger.info("Token refreshed successfully, retrying request")
|
|
232
|
+
else:
|
|
233
|
+
# Second 401: Fall back to DCR
|
|
234
|
+
logger.warning(
|
|
235
|
+
"Token refresh failed to resolve 401, falling back to DCR",
|
|
236
|
+
attempt=attempt + 1,
|
|
237
|
+
)
|
|
238
|
+
await self._auth_manager.perform_dcr()
|
|
239
|
+
new_token = await self._auth_manager.refresh_token()
|
|
240
|
+
self._http_client.set_auth_token(new_token)
|
|
241
|
+
logger.info("DCR and token refresh completed, retrying request")
|
|
242
|
+
|
|
243
|
+
except TokenError as e:
|
|
244
|
+
logger.error(
|
|
245
|
+
"Failed to refresh token",
|
|
246
|
+
error=str(e),
|
|
247
|
+
error_type=type(e).__name__,
|
|
248
|
+
attempt=attempt + 1,
|
|
249
|
+
auth_diagnostics=self._auth_manager.get_diagnostic_info(),
|
|
250
|
+
)
|
|
251
|
+
# If first attempt, we'll try DCR on next iteration
|
|
252
|
+
# If second attempt, we'll fail on next iteration
|
|
253
|
+
if attempt >= 1:
|
|
254
|
+
raise AuthenticationError(
|
|
255
|
+
"Failed to recover from authentication error",
|
|
256
|
+
details={
|
|
257
|
+
"original_error": str(e),
|
|
258
|
+
"diagnostics": self._auth_manager.get_diagnostic_info(),
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(
|
|
263
|
+
"Unexpected error during auth recovery",
|
|
264
|
+
error=str(e),
|
|
265
|
+
error_type=type(e).__name__,
|
|
266
|
+
attempt=attempt + 1,
|
|
267
|
+
exc_info=True,
|
|
268
|
+
)
|
|
269
|
+
raise AuthenticationError(
|
|
270
|
+
"Unexpected error during authentication recovery",
|
|
271
|
+
details={
|
|
272
|
+
"error": str(e),
|
|
273
|
+
"error_type": type(e).__name__,
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def post(
|
|
278
|
+
self,
|
|
279
|
+
path: str,
|
|
280
|
+
body: str,
|
|
281
|
+
headers: Optional[dict] = None,
|
|
282
|
+
) -> HttpMcpResponse:
|
|
283
|
+
"""Send an authenticated POST request.
|
|
284
|
+
|
|
285
|
+
Convenience method that wraps send_request for POST requests.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
path: URL path (will be appended to base URL)
|
|
289
|
+
body: Request body (JSON string)
|
|
290
|
+
headers: Additional headers
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
HTTP MCP response
|
|
294
|
+
"""
|
|
295
|
+
request = HttpMcpRequest(
|
|
296
|
+
method="POST",
|
|
297
|
+
url=path,
|
|
298
|
+
headers=headers or {},
|
|
299
|
+
body=body,
|
|
300
|
+
session_id=self._http_client.session_id,
|
|
301
|
+
)
|
|
302
|
+
return await self.send_request(request)
|
|
303
|
+
|
|
304
|
+
async def get(
|
|
305
|
+
self,
|
|
306
|
+
path: str,
|
|
307
|
+
headers: Optional[dict] = None,
|
|
308
|
+
) -> HttpMcpResponse:
|
|
309
|
+
"""Send an authenticated GET request.
|
|
310
|
+
|
|
311
|
+
Convenience method that wraps send_request for GET requests.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
path: URL path (will be appended to base URL)
|
|
315
|
+
headers: Additional headers
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
HTTP MCP response
|
|
319
|
+
"""
|
|
320
|
+
request = HttpMcpRequest(
|
|
321
|
+
method="GET",
|
|
322
|
+
url=path,
|
|
323
|
+
headers=headers or {},
|
|
324
|
+
session_id=self._http_client.session_id,
|
|
325
|
+
)
|
|
326
|
+
return await self.send_request(request)
|
|
327
|
+
|
|
328
|
+
async def open_sse_stream(
|
|
329
|
+
self,
|
|
330
|
+
path: str = "",
|
|
331
|
+
headers: Optional[dict] = None,
|
|
332
|
+
) -> AsyncIterator[SSEEvent]:
|
|
333
|
+
"""Open an authenticated Server-Sent Events stream.
|
|
334
|
+
|
|
335
|
+
This method ensures a valid token is attached before opening the stream.
|
|
336
|
+
Note: SSE streams are long-lived, so token expiration during the stream
|
|
337
|
+
is not automatically handled. The caller should handle reconnection if needed.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
path: URL path for SSE endpoint (default: base URL)
|
|
341
|
+
headers: Additional headers
|
|
342
|
+
|
|
343
|
+
Yields:
|
|
344
|
+
SSEEvent objects as they arrive from the server
|
|
345
|
+
|
|
346
|
+
Raises:
|
|
347
|
+
AuthenticationError: If authentication fails
|
|
348
|
+
ConnectionError: If connection fails
|
|
349
|
+
StreamError: If stream encounters an error
|
|
350
|
+
"""
|
|
351
|
+
# Ensure we have a valid token before opening stream
|
|
352
|
+
await self._update_http_client_token()
|
|
353
|
+
|
|
354
|
+
# Open the SSE stream (delegates to HTTP client)
|
|
355
|
+
async for event in self._http_client.open_sse_stream(path, headers):
|
|
356
|
+
yield event
|
|
357
|
+
|
|
358
|
+
async def close(self) -> None:
|
|
359
|
+
"""Close the authenticated client and release resources.
|
|
360
|
+
|
|
361
|
+
This closes both the HTTP client and authentication manager.
|
|
362
|
+
"""
|
|
363
|
+
logger.info("Closing authenticated HTTP client")
|
|
364
|
+
|
|
365
|
+
# Close authentication manager first (stops background tasks)
|
|
366
|
+
await self._auth_manager.close()
|
|
367
|
+
|
|
368
|
+
# Close HTTP client (releases connections)
|
|
369
|
+
await self._http_client.close()
|
|
370
|
+
|
|
371
|
+
logger.info("Authenticated HTTP client closed")
|
|
372
|
+
|
|
373
|
+
async def __aenter__(self) -> "AuthenticatedHttpClient":
|
|
374
|
+
"""Async context manager entry."""
|
|
375
|
+
await self.initialize()
|
|
376
|
+
return self
|
|
377
|
+
|
|
378
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
379
|
+
"""Async context manager exit."""
|
|
380
|
+
await self.close()
|
|
381
|
+
|
|
382
|
+
def __repr__(self) -> str:
|
|
383
|
+
"""String representation of the authenticated client."""
|
|
384
|
+
return (
|
|
385
|
+
f"AuthenticatedHttpClient("
|
|
386
|
+
f"authenticated={self._auth_manager.is_token_valid()}, "
|
|
387
|
+
f"session_id={self.session_id})"
|
|
388
|
+
)
|