Skip to content

Commit d9470f6

Browse files
Add hosted http transport with per-request auth (multi-tenant) (#3)
VyMCP can now run as one shared server (VYMCP_TRANSPORT=http) that many engineers connect to, each authenticating with their own vym_ token per request — instead of everyone running a local stdio instance. - ServerConfig splits server settings from the per-request token; api_token only required for stdio - VyManagerTokenVerifier validates each request's bearer token against VyManager (cached); tools resolve a per-token client via current_client() - plans are owner-scoped so one engineer can't apply another's pending change - main() runs stdio or streamable-http per config; stdio path unchanged Verified live against VyManager: http client connects with a bearer token and acts as that user; unauthenticated/invalid tokens rejected; a second token cannot apply the first's plan.
1 parent 3c2c694 commit d9470f6

12 files changed

Lines changed: 299 additions & 95 deletions

.env.example

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
# Base URL of the VyManager API (no trailing slash).
22
VYMANAGER_BASE_URL=http://localhost:8000
33

4-
# A VyManager personal access token (Sites -> API Tokens). Read-only recommended.
5-
VYMANAGER_API_TOKEN=vym_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
6-
74
# Verify the VyManager TLS certificate (set to false only for trusted dev setups).
85
VYMANAGER_VERIFY_SSL=true
96

107
# Request timeout in seconds.
118
VYMANAGER_TIMEOUT=30
129

13-
# Enable configuration-changing tools (off by default). This is a kill-switch
14-
# independent of the token: leave unset/false for a strictly read-only server.
15-
# Even when true, a read-only token still cannot write (VyManager enforces it).
10+
# Enable configuration-changing tools (off by default). Kill-switch independent of
11+
# the token: even a read-write token cannot change config unless this is true.
1612
VYMANAGER_ENABLE_WRITES=false
13+
14+
# --- Transport --------------------------------------------------------------
15+
# "stdio" (default): the MCP client launches VyMCP locally, one process per user.
16+
# "http": VyMCP runs as a shared server; each client authenticates per request.
17+
VYMCP_TRANSPORT=stdio
18+
19+
# stdio only: the personal access token this local server uses (Sites -> API
20+
# Tokens; read-only recommended). Not used in http mode — clients present their
21+
# own bearer token per request.
22+
VYMANAGER_API_TOKEN=vym_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
23+
24+
# http only: bind address, port, and the externally reachable URL.
25+
VYMCP_HOST=127.0.0.1
26+
VYMCP_PORT=8080
27+
# VYMCP_PUBLIC_URL=https://vymcp.example.com

src/vymcp/auth.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Bearer-token verification for the hosted (http) transport.
2+
3+
Each request carries a ``vym_`` token; we validate it against VyManager and hand
4+
back an AccessToken whose subject is a stable, token-derived id used to scope the
5+
caller's pending plans. The raw token is carried through so tools can act as that
6+
caller (VyManager still enforces the token's RBAC and records the real user).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import hashlib
12+
import time
13+
14+
from mcp.server.auth.provider import AccessToken, TokenVerifier
15+
16+
from .client import VyManagerClient
17+
from .config import ServerConfig
18+
19+
# A cheap authenticated endpoint: 200 = valid token, 401 = not.
20+
_VALIDATE_PATH = "/session/sites"
21+
_CACHE_TTL_SECONDS = 60.0
22+
23+
24+
class VyManagerTokenVerifier(TokenVerifier):
25+
def __init__(self, server_config: ServerConfig) -> None:
26+
self._server_config = server_config
27+
self._cache: dict[str, tuple[float, AccessToken]] = {}
28+
29+
async def verify_token(self, token: str) -> AccessToken | None:
30+
now = time.monotonic()
31+
cached = self._cache.get(token)
32+
if cached and cached[0] > now:
33+
return cached[1]
34+
35+
client = VyManagerClient(self._server_config.client_config(token))
36+
try:
37+
await client.get(_VALIDATE_PATH)
38+
except Exception:
39+
return None
40+
finally:
41+
await client.aclose()
42+
43+
subject = "vym_" + hashlib.sha256(token.encode()).hexdigest()[:16]
44+
access = AccessToken(token=token, client_id=subject, scopes=[], subject=subject)
45+
self._cache[token] = (now + _CACHE_TTL_SECONDS, access)
46+
return access

src/vymcp/changes.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
@dataclass
2626
class Plan:
2727
plan_id: str
28+
owner: str # the caller who proposed it; only they may apply it
2829
instance_id: str
2930
feature: str
3031
path: str # VyManager endpoint the apply will POST to
@@ -50,6 +51,7 @@ def _prune(self) -> None:
5051
def create(
5152
self,
5253
*,
54+
owner: str,
5355
instance_id: str,
5456
feature: str,
5557
path: str,
@@ -60,6 +62,7 @@ def create(
6062
self._prune()
6163
plan = Plan(
6264
plan_id="plan_" + secrets.token_hex(8),
65+
owner=owner,
6366
instance_id=instance_id,
6467
feature=feature,
6568
path=path,
@@ -70,9 +73,13 @@ def create(
7073
self._plans[plan.plan_id] = plan
7174
return plan
7275

73-
def get(self, plan_id: str) -> Plan | None:
76+
def get(self, plan_id: str, owner: str) -> Plan | None:
77+
"""Return the plan only if it exists and belongs to ``owner``."""
7478
self._prune()
75-
return self._plans.get(plan_id)
79+
plan = self._plans.get(plan_id)
80+
if plan is None or plan.owner != owner:
81+
return None
82+
return plan
7683

7784
def consume(self, plan_id: str) -> None:
7885
"""Remove a plan after it has been successfully applied (single-use)."""

src/vymcp/client.py

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import httpx
1313

14-
from .config import Config
14+
from .config import Config, ServerConfig
1515

1616
logger = logging.getLogger("vymcp")
1717

@@ -122,28 +122,78 @@ def _explain_error(response: httpx.Response, instance_id: str | None) -> str:
122122
return f"VyManager request failed ({status})." + (f" {detail}" if detail else "")
123123

124124

125-
_client: VyManagerClient | None = None
125+
_server_config: ServerConfig | None = None
126+
_clients: dict[str, VyManagerClient] = {}
127+
_override: VyManagerClient | None = None # test hook
126128

127129

128-
def get_client() -> VyManagerClient:
129-
"""Lazily build a shared VyManager client from the environment on first use."""
130-
global _client
131-
if _client is None:
132-
config = Config.from_env()
133-
logger.info("Connecting to VyManager at %s", config.base_url)
134-
_client = VyManagerClient(config)
135-
return _client
130+
def server_config() -> ServerConfig:
131+
global _server_config
132+
if _server_config is None:
133+
_server_config = ServerConfig.from_env()
134+
logger.info(
135+
"VyManager %s (%s transport)", _server_config.base_url, _server_config.transport
136+
)
137+
return _server_config
138+
139+
140+
def _current_token() -> str:
141+
"""The token for the current request: the caller's bearer token in http mode,
142+
or the server's env token in stdio mode."""
143+
try:
144+
from mcp.server.auth.middleware.auth_context import get_access_token
145+
146+
access = get_access_token()
147+
except Exception:
148+
access = None
149+
if access is not None:
150+
return access.token
151+
152+
token = server_config().api_token
153+
if not token:
154+
raise VyManagerError("No API token available for this request.")
155+
return token
156+
157+
158+
def current_client() -> VyManagerClient:
159+
"""The VyManager client for the current request's identity.
160+
161+
In http mode each caller's token gets its own (cached) client; in stdio mode
162+
there is a single env-token client. Tests can override via set_client().
163+
"""
164+
if _override is not None:
165+
return _override
166+
token = _current_token()
167+
if token not in _clients:
168+
_clients[token] = VyManagerClient(server_config().client_config(token))
169+
return _clients[token]
170+
171+
172+
def current_owner() -> str:
173+
"""A stable identity for the current caller, used to scope pending plans."""
174+
try:
175+
from mcp.server.auth.middleware.auth_context import get_access_token
176+
177+
access = get_access_token()
178+
except Exception:
179+
access = None
180+
if access is not None and access.subject:
181+
return access.subject
182+
return "local"
136183

137184

138185
def set_client(client: VyManagerClient | None) -> None:
139-
"""Override the shared client (used by tests)."""
140-
global _client
141-
_client = client
186+
"""Override the client for all requests (used by tests)."""
187+
global _override
188+
_override = client
142189

143190

144191
async def close_client() -> None:
145-
"""Close the shared client on shutdown."""
146-
global _client
147-
if _client is not None:
148-
await _client.aclose()
149-
_client = None
192+
"""Close all cached clients on shutdown."""
193+
global _override
194+
for client in list(_clients.values()):
195+
await client.aclose()
196+
_clients.clear()
197+
if _override is not None:
198+
await _override.aclose()
199+
_override = None

src/vymcp/config.py

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,47 +15,85 @@ def writes_enabled() -> bool:
1515
This is the operator kill-switch, independent of the token's own scope: even
1616
a read-write token cannot change config unless writes are explicitly enabled.
1717
"""
18-
import os
19-
2018
return os.environ.get("VYMANAGER_ENABLE_WRITES", "false").strip().lower() in _TRUTHY
2119

2220

21+
def _verify_ssl() -> bool:
22+
return os.environ.get("VYMANAGER_VERIFY_SSL", "true").lower() not in _FALSEY
23+
24+
25+
def _timeout() -> float:
26+
try:
27+
return float(os.environ.get("VYMANAGER_TIMEOUT", "30"))
28+
except ValueError:
29+
return 30.0
30+
31+
2332
@dataclass(frozen=True)
2433
class Config:
34+
"""Per-client config: everything needed to talk to VyManager as one token."""
35+
2536
base_url: str
2637
api_token: str
2738
verify_ssl: bool = True
2839
timeout: float = 30.0
2940

41+
42+
@dataclass(frozen=True)
43+
class ServerConfig:
44+
"""Server-level config. In http mode the token arrives per request, so
45+
``api_token`` is only required for stdio (single-tenant, local) mode."""
46+
47+
base_url: str
48+
verify_ssl: bool = True
49+
timeout: float = 30.0
50+
transport: str = "stdio" # "stdio" or "http"
51+
host: str = "127.0.0.1"
52+
port: int = 8080
53+
public_url: str = "http://127.0.0.1:8080" # externally reachable URL (http mode)
54+
api_token: str | None = None
55+
3056
@classmethod
31-
def from_env(cls) -> Config:
57+
def from_env(cls) -> ServerConfig:
3258
base_url = os.environ.get("VYMANAGER_BASE_URL")
33-
api_token = os.environ.get("VYMANAGER_API_TOKEN")
59+
if not base_url:
60+
raise RuntimeError("Missing required environment variable: VYMANAGER_BASE_URL.")
3461

35-
missing = [
36-
name
37-
for name, value in (
38-
("VYMANAGER_BASE_URL", base_url),
39-
("VYMANAGER_API_TOKEN", api_token),
40-
)
41-
if not value
42-
]
43-
if missing or base_url is None or api_token is None:
62+
transport = os.environ.get("VYMCP_TRANSPORT", "stdio").strip().lower()
63+
if transport not in {"stdio", "http"}:
64+
raise RuntimeError(f"VYMCP_TRANSPORT must be 'stdio' or 'http', got '{transport}'.")
65+
66+
api_token = os.environ.get("VYMANAGER_API_TOKEN")
67+
if transport == "stdio" and not api_token:
4468
raise RuntimeError(
45-
"Missing required environment variable(s): "
46-
+ ", ".join(missing)
47-
+ ". See .env.example."
69+
"VYMANAGER_API_TOKEN is required for stdio transport. "
70+
"(In http mode, clients present their own token per request.)"
4871
)
4972

50-
verify_ssl = os.environ.get("VYMANAGER_VERIFY_SSL", "true").lower() not in _FALSEY
5173
try:
52-
timeout = float(os.environ.get("VYMANAGER_TIMEOUT", "30"))
74+
port = int(os.environ.get("VYMCP_PORT", "8080"))
5375
except ValueError:
54-
timeout = 30.0
76+
port = 8080
77+
78+
host = os.environ.get("VYMCP_HOST", "127.0.0.1")
79+
public_url = os.environ.get("VYMCP_PUBLIC_URL") or f"http://{host}:{port}"
5580

5681
return cls(
5782
base_url=base_url.rstrip("/"),
83+
verify_ssl=_verify_ssl(),
84+
timeout=_timeout(),
85+
transport=transport,
86+
host=host,
87+
port=port,
88+
public_url=public_url.rstrip("/"),
5889
api_token=api_token,
59-
verify_ssl=verify_ssl,
60-
timeout=timeout,
90+
)
91+
92+
def client_config(self, token: str) -> Config:
93+
"""Build a per-client Config for a specific token."""
94+
return Config(
95+
base_url=self.base_url,
96+
api_token=token,
97+
verify_ssl=self.verify_ssl,
98+
timeout=self.timeout,
6199
)

src/vymcp/discovery.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from typing import Any
1717

18-
from .client import get_client
18+
from .client import current_client
1919

2020
_feature_cache: dict[str, dict[str, Any]] = {}
2121
_openapi_spec: dict[str, Any] | None = None
@@ -30,7 +30,7 @@ def clear_cache() -> None:
3030

3131
async def _get_feature(feature: str) -> dict[str, Any]:
3232
if feature not in _feature_cache:
33-
_feature_cache[feature] = await get_client().get(f"/vyos/operations/{feature}")
33+
_feature_cache[feature] = await current_client().get(f"/vyos/operations/{feature}")
3434
return _feature_cache[feature]
3535

3636

@@ -64,7 +64,7 @@ async def get_top_level_fields(feature: str) -> dict[str, dict[str, Any]]:
6464
global _openapi_spec
6565
if _openapi_spec is None:
6666
try:
67-
_openapi_spec = await get_client().get("/openapi.json")
67+
_openapi_spec = await current_client().get("/openapi.json")
6868
except Exception:
6969
return {}
7070

0 commit comments

Comments
 (0)