Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
# Base URL of the VyManager API (no trailing slash).
VYMANAGER_BASE_URL=http://localhost:8000

# A VyManager personal access token (Sites -> API Tokens). Read-only recommended.
VYMANAGER_API_TOKEN=vym_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# Verify the VyManager TLS certificate (set to false only for trusted dev setups).
VYMANAGER_VERIFY_SSL=true

# Request timeout in seconds.
VYMANAGER_TIMEOUT=30

# Enable configuration-changing tools (off by default). This is a kill-switch
# independent of the token: leave unset/false for a strictly read-only server.
# Even when true, a read-only token still cannot write (VyManager enforces it).
# Enable configuration-changing tools (off by default). Kill-switch independent of
# the token: even a read-write token cannot change config unless this is true.
VYMANAGER_ENABLE_WRITES=false

# --- Transport --------------------------------------------------------------
# "stdio" (default): the MCP client launches VyMCP locally, one process per user.
# "http": VyMCP runs as a shared server; each client authenticates per request.
VYMCP_TRANSPORT=stdio

# stdio only: the personal access token this local server uses (Sites -> API
# Tokens; read-only recommended). Not used in http mode — clients present their
# own bearer token per request.
VYMANAGER_API_TOKEN=vym_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# http only: bind address, port, and the externally reachable URL.
VYMCP_HOST=127.0.0.1
VYMCP_PORT=8080
# VYMCP_PUBLIC_URL=https://vymcp.example.com
46 changes: 46 additions & 0 deletions src/vymcp/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Bearer-token verification for the hosted (http) transport.

Each request carries a ``vym_`` token; we validate it against VyManager and hand
back an AccessToken whose subject is a stable, token-derived id used to scope the
caller's pending plans. The raw token is carried through so tools can act as that
caller (VyManager still enforces the token's RBAC and records the real user).
"""

from __future__ import annotations

import hashlib
import time

from mcp.server.auth.provider import AccessToken, TokenVerifier

from .client import VyManagerClient
from .config import ServerConfig

# A cheap authenticated endpoint: 200 = valid token, 401 = not.
_VALIDATE_PATH = "/session/sites"
_CACHE_TTL_SECONDS = 60.0


class VyManagerTokenVerifier(TokenVerifier):
def __init__(self, server_config: ServerConfig) -> None:
self._server_config = server_config
self._cache: dict[str, tuple[float, AccessToken]] = {}

async def verify_token(self, token: str) -> AccessToken | None:
now = time.monotonic()
cached = self._cache.get(token)
if cached and cached[0] > now:
return cached[1]

client = VyManagerClient(self._server_config.client_config(token))
try:
await client.get(_VALIDATE_PATH)
except Exception:
return None
finally:
await client.aclose()

subject = "vym_" + hashlib.sha256(token.encode()).hexdigest()[:16]
access = AccessToken(token=token, client_id=subject, scopes=[], subject=subject)
self._cache[token] = (now + _CACHE_TTL_SECONDS, access)
return access
11 changes: 9 additions & 2 deletions src/vymcp/changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
@dataclass
class Plan:
plan_id: str
owner: str # the caller who proposed it; only they may apply it
instance_id: str
feature: str
path: str # VyManager endpoint the apply will POST to
Expand All @@ -50,6 +51,7 @@ def _prune(self) -> None:
def create(
self,
*,
owner: str,
instance_id: str,
feature: str,
path: str,
Expand All @@ -60,6 +62,7 @@ def create(
self._prune()
plan = Plan(
plan_id="plan_" + secrets.token_hex(8),
owner=owner,
instance_id=instance_id,
feature=feature,
path=path,
Expand All @@ -70,9 +73,13 @@ def create(
self._plans[plan.plan_id] = plan
return plan

def get(self, plan_id: str) -> Plan | None:
def get(self, plan_id: str, owner: str) -> Plan | None:
"""Return the plan only if it exists and belongs to ``owner``."""
self._prune()
return self._plans.get(plan_id)
plan = self._plans.get(plan_id)
if plan is None or plan.owner != owner:
return None
return plan

def consume(self, plan_id: str) -> None:
"""Remove a plan after it has been successfully applied (single-use)."""
Expand Down
86 changes: 68 additions & 18 deletions src/vymcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import httpx

from .config import Config
from .config import Config, ServerConfig

logger = logging.getLogger("vymcp")

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


_client: VyManagerClient | None = None
_server_config: ServerConfig | None = None
_clients: dict[str, VyManagerClient] = {}
_override: VyManagerClient | None = None # test hook


def get_client() -> VyManagerClient:
"""Lazily build a shared VyManager client from the environment on first use."""
global _client
if _client is None:
config = Config.from_env()
logger.info("Connecting to VyManager at %s", config.base_url)
_client = VyManagerClient(config)
return _client
def server_config() -> ServerConfig:
global _server_config
if _server_config is None:
_server_config = ServerConfig.from_env()
logger.info(
"VyManager %s (%s transport)", _server_config.base_url, _server_config.transport
)
return _server_config


def _current_token() -> str:
"""The token for the current request: the caller's bearer token in http mode,
or the server's env token in stdio mode."""
try:
from mcp.server.auth.middleware.auth_context import get_access_token

access = get_access_token()
except Exception:
access = None
if access is not None:
return access.token

token = server_config().api_token
if not token:
raise VyManagerError("No API token available for this request.")
return token


def current_client() -> VyManagerClient:
"""The VyManager client for the current request's identity.

In http mode each caller's token gets its own (cached) client; in stdio mode
there is a single env-token client. Tests can override via set_client().
"""
if _override is not None:
return _override
token = _current_token()
if token not in _clients:
_clients[token] = VyManagerClient(server_config().client_config(token))
return _clients[token]


def current_owner() -> str:
"""A stable identity for the current caller, used to scope pending plans."""
try:
from mcp.server.auth.middleware.auth_context import get_access_token

access = get_access_token()
except Exception:
access = None
if access is not None and access.subject:
return access.subject
return "local"


def set_client(client: VyManagerClient | None) -> None:
"""Override the shared client (used by tests)."""
global _client
_client = client
"""Override the client for all requests (used by tests)."""
global _override
_override = client


async def close_client() -> None:
"""Close the shared client on shutdown."""
global _client
if _client is not None:
await _client.aclose()
_client = None
"""Close all cached clients on shutdown."""
global _override
for client in list(_clients.values()):
await client.aclose()
_clients.clear()
if _override is not None:
await _override.aclose()
_override = None
80 changes: 59 additions & 21 deletions src/vymcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,47 +15,85 @@ def writes_enabled() -> bool:
This is the operator kill-switch, independent of the token's own scope: even
a read-write token cannot change config unless writes are explicitly enabled.
"""
import os

return os.environ.get("VYMANAGER_ENABLE_WRITES", "false").strip().lower() in _TRUTHY


def _verify_ssl() -> bool:
return os.environ.get("VYMANAGER_VERIFY_SSL", "true").lower() not in _FALSEY


def _timeout() -> float:
try:
return float(os.environ.get("VYMANAGER_TIMEOUT", "30"))
except ValueError:
return 30.0


@dataclass(frozen=True)
class Config:
"""Per-client config: everything needed to talk to VyManager as one token."""

base_url: str
api_token: str
verify_ssl: bool = True
timeout: float = 30.0


@dataclass(frozen=True)
class ServerConfig:
"""Server-level config. In http mode the token arrives per request, so
``api_token`` is only required for stdio (single-tenant, local) mode."""

base_url: str
verify_ssl: bool = True
timeout: float = 30.0
transport: str = "stdio" # "stdio" or "http"
host: str = "127.0.0.1"
port: int = 8080
public_url: str = "http://127.0.0.1:8080" # externally reachable URL (http mode)
api_token: str | None = None

@classmethod
def from_env(cls) -> Config:
def from_env(cls) -> ServerConfig:
base_url = os.environ.get("VYMANAGER_BASE_URL")
api_token = os.environ.get("VYMANAGER_API_TOKEN")
if not base_url:
raise RuntimeError("Missing required environment variable: VYMANAGER_BASE_URL.")

missing = [
name
for name, value in (
("VYMANAGER_BASE_URL", base_url),
("VYMANAGER_API_TOKEN", api_token),
)
if not value
]
if missing or base_url is None or api_token is None:
transport = os.environ.get("VYMCP_TRANSPORT", "stdio").strip().lower()
if transport not in {"stdio", "http"}:
raise RuntimeError(f"VYMCP_TRANSPORT must be 'stdio' or 'http', got '{transport}'.")

api_token = os.environ.get("VYMANAGER_API_TOKEN")
if transport == "stdio" and not api_token:
raise RuntimeError(
"Missing required environment variable(s): "
+ ", ".join(missing)
+ ". See .env.example."
"VYMANAGER_API_TOKEN is required for stdio transport. "
"(In http mode, clients present their own token per request.)"
)

verify_ssl = os.environ.get("VYMANAGER_VERIFY_SSL", "true").lower() not in _FALSEY
try:
timeout = float(os.environ.get("VYMANAGER_TIMEOUT", "30"))
port = int(os.environ.get("VYMCP_PORT", "8080"))
except ValueError:
timeout = 30.0
port = 8080

host = os.environ.get("VYMCP_HOST", "127.0.0.1")
public_url = os.environ.get("VYMCP_PUBLIC_URL") or f"http://{host}:{port}"

return cls(
base_url=base_url.rstrip("/"),
verify_ssl=_verify_ssl(),
timeout=_timeout(),
transport=transport,
host=host,
port=port,
public_url=public_url.rstrip("/"),
api_token=api_token,
verify_ssl=verify_ssl,
timeout=timeout,
)

def client_config(self, token: str) -> Config:
"""Build a per-client Config for a specific token."""
return Config(
base_url=self.base_url,
api_token=token,
verify_ssl=self.verify_ssl,
timeout=self.timeout,
)
6 changes: 3 additions & 3 deletions src/vymcp/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from typing import Any

from .client import get_client
from .client import current_client

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

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


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

Expand Down
Loading
Loading