33
44from __future__ import annotations
55
6+ import asyncio
67import html
78import logging
8- from contextlib import AsyncExitStack
9+ import shlex
10+ import socket
11+ from contextlib import AsyncExitStack , asynccontextmanager
912from datetime import timedelta
10- from secrets import token_urlsafe
13+ from secrets import token_hex , token_urlsafe
1114from urllib .parse import parse_qs , urlencode , urlparse , urlunparse
1215from uuid import UUID
1316
@@ -54,13 +57,17 @@ async def create_connector(
5457 client_secret : str | None ,
5558 metadata : Metadata | None ,
5659 match_preset : bool = True ,
60+ bearer_token : str | None = None ,
5761 ) -> Connector :
5862 if client_secret and not client_id :
5963 raise PlatformError (
6064 "client_id must be present when client_secret is specified" , status_code = status .HTTP_400_BAD_REQUEST
6165 )
6266
6367 preset = self ._find_preset (url = url ) if match_preset else None
68+ if not preset and url .scheme not in {"http" , "https" }:
69+ raise PlatformError ("Unknown connector preset" , status_code = status .HTTP_400_BAD_REQUEST )
70+
6471 if preset :
6572 if not client_id :
6673 client_id = preset .client_id
@@ -72,6 +79,7 @@ async def create_connector(
7279 created_by = user .id ,
7380 auth = Authorization (client_id = client_id , client_secret = client_secret ) if client_id else None ,
7481 metadata = metadata ,
82+ bearer_token = bearer_token ,
7583 )
7684 async with self ._uow () as uow :
7785 await uow .connectors .create (connector = connector )
@@ -368,10 +376,19 @@ def client_factory(headers=None, timeout=None, auth=None):
368376 return self ._create_client (connector = connector , headers = headers , timeout = timeout )
369377
370378 try :
371- async with (
372- streamablehttp_client (str (connector .url ), httpx_client_factory = client_factory ) as (read , write , _ ),
373- ClientSession (read , write ) as session ,
374- ):
379+ async with AsyncExitStack () as stack :
380+ read , write , _ = await stack .enter_async_context (
381+ streamablehttp_client (
382+ (
383+ f"{ await stack .enter_async_context (self ._stdio_gateway (connector = connector , preset = preset ))} /mcp"
384+ if (preset := self ._find_preset (url = connector .url ))
385+ and str (preset .url ).startswith ("mcp+stdio://" )
386+ else str (connector .url )
387+ ),
388+ httpx_client_factory = client_factory ,
389+ )
390+ )
391+ session = await stack .enter_async_context (ClientSession (read , write ))
375392 await session .initialize ()
376393 except ExceptionGroup as excgroup :
377394 if len (excgroup .exceptions ) == 1 :
@@ -380,6 +397,7 @@ def client_factory(headers=None, timeout=None, auth=None):
380397
381398 async def mcp_proxy (self , * , connector_id : UUID , request : Request , user : User | None = None ):
382399 connector = await self .read_connector (connector_id = connector_id , user = user )
400+ preset = self ._find_preset (url = connector .url )
383401
384402 forward_headers = {
385403 key : request .headers [key ]
@@ -389,17 +407,24 @@ async def mcp_proxy(self, *, connector_id: UUID, request: Request, user: User |
389407
390408 exit_stack = AsyncExitStack ()
391409 try :
410+ target_url = (
411+ f"{ await exit_stack .enter_async_context (self ._stdio_gateway (connector = connector , preset = preset ))} /mcp"
412+ if preset and str (preset .url ).startswith ("mcp+stdio://" )
413+ else str (connector .url )
414+ )
392415 response = await exit_stack .enter_async_context (
393416 self ._proxy_client .stream (
394417 request .method ,
395- str ( connector . url ) ,
418+ target_url ,
396419 headers = forward_headers
397420 | (
398421 {"authorization" : f"Bearer { connector .auth .token .access_token } " }
399422 if connector .state == ConnectorState .connected
400423 and connector .auth
401424 and connector .auth .token
402425 and connector .auth .token .token_type == "bearer"
426+ else {"authorization" : f"Bearer { preset .bearer_token } " }
427+ if preset and preset .bearer_token
403428 else {}
404429 ),
405430 content = request .stream (),
@@ -418,6 +443,113 @@ async def stream_fn():
418443 await exit_stack .pop_all ().aclose ()
419444 raise
420445
446+ @asynccontextmanager
447+ async def _stdio_gateway (self , * , connector : Connector , preset : ConnectorPreset ):
448+ log_tasks : list [asyncio .Task ] = []
449+ process = None
450+ try :
451+ namespace = self ._configuration .connector .runtime .namespace or self ._configuration .k8s_namespace
452+ kubeconfig = self ._configuration .connector .runtime .kubeconfig or self ._configuration .k8s_kubeconfig
453+ with socket .socket (socket .AF_INET , socket .SOCK_STREAM ) as sock :
454+ sock .bind (("127.0.0.1" , 0 ))
455+ port = sock .getsockname ()[1 ]
456+
457+ process = await asyncio .create_subprocess_exec (
458+ "supergateway" ,
459+ "--stdio" ,
460+ shlex .join (
461+ [
462+ "kubectl" ,
463+ "run" ,
464+ f"conn-{ connector .id .hex [:6 ]} -{ token_hex (3 )} " [:63 ],
465+ "--rm=true" ,
466+ "--restart=Never" ,
467+ "--attach=true" ,
468+ "--stdin=true" ,
469+ "--tty=false" ,
470+ "--image" ,
471+ preset .stdio .image ,
472+ "--image-pull-policy" ,
473+ "IfNotPresent" ,
474+ * (["--namespace" , namespace ] if namespace else []),
475+ * (["--kubeconfig" , str (kubeconfig )] if kubeconfig else []),
476+ * (f"--env={ key } ={ val } " for key , val in preset .stdio .env .items ()),
477+ * (["--command" , "--" , * preset .stdio .command ] if preset .stdio .command else ["--" ]),
478+ * (preset .stdio .args or []),
479+ ]
480+ ),
481+ "--outputTransport" ,
482+ "streamableHttp" ,
483+ "--port" ,
484+ str (port ),
485+ "--streamableHttpPath" ,
486+ "/mcp" ,
487+ "--logLevel" ,
488+ "info" ,
489+ stdout = asyncio .subprocess .PIPE ,
490+ stderr = asyncio .subprocess .PIPE ,
491+ )
492+ if process .stdout :
493+ log_tasks .append (
494+ asyncio .create_task (
495+ self ._log_process_stream (process .stdout , logging .INFO , f"connector[{ connector .id } ]" )
496+ )
497+ )
498+ if process .stderr :
499+ log_tasks .append (
500+ asyncio .create_task (
501+ self ._log_process_stream (process .stderr , logging .WARNING , f"connector[{ connector .id } ]" )
502+ )
503+ )
504+ await self ._wait_for_port (
505+ port = port ,
506+ timeout_seconds = self ._configuration .connector .runtime .startup_timeout_seconds ,
507+ process = process ,
508+ )
509+ yield f"http://127.0.0.1:{ port } "
510+ finally :
511+ for task in log_tasks :
512+ task .cancel ()
513+ if process and process .returncode is None :
514+ process .terminate ()
515+ async with asyncio .timeout (5 ):
516+ await process .wait ()
517+ if process .returncode is None :
518+ process .kill ()
519+
520+ async def _log_process_stream (self , stream : asyncio .StreamReader , level : int , prefix : str ):
521+ try :
522+ while line := await stream .readline ():
523+ logger .log (level , "%s %s" , prefix , line .decode ("utf-8" , errors = "replace" ).rstrip ())
524+ except asyncio .CancelledError :
525+ pass
526+
527+ async def _wait_for_port (
528+ self , * , port : int , timeout_seconds : int , process : asyncio .subprocess .Process | None = None
529+ ) -> None :
530+ loop = asyncio .get_running_loop ()
531+ deadline = loop .time () + timeout_seconds
532+ while True :
533+ if process and process .returncode is not None :
534+ raise PlatformError (
535+ "Failed to start stdio connector" ,
536+ status_code = status .HTTP_502_BAD_GATEWAY ,
537+ )
538+ try :
539+ _reader , writer = await asyncio .open_connection ("127.0.0.1" , port )
540+ except OSError as err :
541+ if loop .time () >= deadline :
542+ raise PlatformError (
543+ "Timed out while starting stdio connector" ,
544+ status_code = status .HTTP_504_GATEWAY_TIMEOUT ,
545+ ) from err
546+ await asyncio .sleep (0.1 )
547+ continue
548+ else :
549+ writer .close ()
550+ await writer .wait_closed ()
551+ return
552+
421553
422554@alru_cache (ttl = timedelta (days = 1 ).seconds )
423555async def _register_client (resource_server_url : str , * , redirect_uri : str ) -> _ClientRegistrationResponse :
0 commit comments