Skip to content

Commit 041c7f5

Browse files
committed
wip: support stdio mcp
Signed-off-by: Jan Pokorný <JenomPokorny@gmail.com>
1 parent ff17fd2 commit 041c7f5

6 files changed

Lines changed: 171 additions & 8 deletions

File tree

apps/agentstack-server/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
FROM python:3.13-alpine3.22 AS builder
1+
FROM python:3.13-alpine3.22
22
ENV UV_COMPILE_BYTECODE=1 \
33
HOME="/tmp" \
44
AGENT_REGISTRY__LOCATIONS__FILE="file:///app/registry.yaml"
5+
RUN apk add --no-cache kubectl nodejs npm \
6+
&& npm install -g supergateway
57
RUN --mount=type=cache,target=/tmp/.cache/uv \
68
--mount=type=bind,source=dist/requirements.txt,target=/requirements.txt \
79
--mount=type=bind,from=ghcr.io/astral-sh/uv:0.9.5,source=/uv,target=/bin/uv \

apps/agentstack-server/src/agentstack_server/api/routes/connectors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ async def create_connector(
5151
client_secret=request.client_secret,
5252
metadata=request.metadata,
5353
match_preset=request.match_preset,
54+
bearer_token=request.bearer_token,
5455
)
5556
)
5657

apps/agentstack-server/src/agentstack_server/api/schema/connector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class ConnectorCreateRequest(BaseModel):
1919
metadata: Metadata | None = None
2020

2121
match_preset: bool = True
22+
bearer_token: str | None = None
2223

2324

2425
class AuthorizationCodeRequest(BaseModel):

apps/agentstack-server/src/agentstack_server/configuration.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,15 +219,31 @@ class ManagedProviderConfiguration(BaseModel):
219219
)
220220

221221

222+
class ConnectorStdioPreset(BaseModel):
223+
image: str
224+
command: list[str] | None = None
225+
args: list[str] | None = None
226+
env: dict[str, str] = Field(default_factory=dict)
227+
228+
222229
class ConnectorPreset(BaseModel):
223230
url: AnyUrl
224231
client_id: str | None = None
225232
client_secret: str | None = None
226233
metadata: dict[str, str] | None = None
234+
bearer_token: str | None = None
235+
stdio: ConnectorStdioPreset | None = None
236+
237+
238+
class ConnectorRuntimeConfiguration(BaseModel):
239+
kubeconfig: Path | None = None
240+
namespace: str | None = None
241+
startup_timeout_seconds: int = 60
227242

228243

229244
class ConnectorConfiguration(BaseModel):
230245
presets: list[ConnectorPreset] = Field(default_factory=list)
246+
runtime: ConnectorRuntimeConfiguration = Field(default_factory=ConnectorRuntimeConfiguration)
231247

232248

233249
class DoclingExtractionConfiguration(BaseModel):

apps/agentstack-server/src/agentstack_server/service_layer/services/connector.py

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33

44
from __future__ import annotations
55

6+
import asyncio
67
import html
78
import logging
8-
from contextlib import AsyncExitStack
9+
import shlex
10+
import socket
11+
from contextlib import AsyncExitStack, asynccontextmanager
912
from datetime import timedelta
10-
from secrets import token_urlsafe
13+
from secrets import token_hex, token_urlsafe
1114
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
1215
from 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)
423555
async def _register_client(resource_server_url: str, *, redirect_uri: str) -> _ClientRegistrationResponse:

helm/values.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ affinity: { }
243243
uvicornTimeoutKeepAlive: 5
244244

245245
connector:
246+
runtime:
247+
namespace: ""
248+
startupTimeoutSeconds: 60
246249
presets:
247250
- url: "https://mcp.stripe.com"
248251
metadata:
@@ -260,6 +263,14 @@ connector:
260263
metadata:
261264
name: "GitHub"
262265
description: "Access and interact with your GitHub repositories and code intelligence"
266+
- url: "mcp+stdio://filesystem"
267+
metadata:
268+
name: "Filesystem"
269+
description: "Run the stdio filesystem MCP server inside the cluster"
270+
stdio:
271+
image: "ghcr.io/modelcontextprotocol/python-servers/filesystem:latest"
272+
args:
273+
- "--root=/data"
263274
ui:
264275
replicaCount: 1
265276
enabled: true

0 commit comments

Comments
 (0)