feat: compose authenticated stdio MCP with explicit runtime tool mapping
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 12:38:04 +02:00
parent 1ece969f59
commit 3c5cbfbafe
14 changed files with 465 additions and 81 deletions

View file

@ -1,3 +1,3 @@
from hub_core.mcp.server import CORE_TOOL_NAMES, HubCoreMCPServer
from hub_core.mcp.server import CORE_TOOL_NAMES, RUNTIME_TOOL_NAMES, HubCoreMCPServer
__all__ = ["CORE_TOOL_NAMES", "HubCoreMCPServer"]
__all__ = ["CORE_TOOL_NAMES", "RUNTIME_TOOL_NAMES", "HubCoreMCPServer"]

View file

@ -0,0 +1,27 @@
"""Single-principal stdio credentials; never use this for a shared MCP listener."""
from pathlib import Path
class StdioTokenFile:
"""Reread an operator-projected Hub-audience credential for every request.
This does not mint credentials or authenticate multiple callers. The process
and its private stdio transport must belong to the one admitted principal.
The Hub API validates issuer/audience/expiry and current authority.
"""
def __init__(self, path: Path):
if not path.is_absolute():
raise ValueError("absolute MCP credential path required")
self.path = path
def __call__(self) -> str:
# A bounded read prevents an accidentally mounted large file from being
# loaded. Errors are sanitized by the MCP HTTP adapter.
with self.path.open("r", encoding="ascii") as stream:
raw = stream.read(16386)
if len(raw) > 16385:
raise ValueError("current stdio credential unavailable")
value = raw.strip()
if not value or len(value) > 16384 or any(c.isspace() for c in value):
raise ValueError("current stdio credential unavailable")
return value

View file

@ -3,11 +3,19 @@ from __future__ import annotations
import json
from typing import Any
from collections.abc import Callable
from urllib.parse import quote
import httpx
from fastmcp import FastMCP
from hub_core.utils.routing import normalize_trailing_slash
from hub_core.security.identity import require_https
# Only these generic tools have equivalent standalone runtime routes.
RUNTIME_TOOL_NAMES = frozenset({
"query_repository_navigation", "get_repository_navigation_facet",
"query_workloads", "resolve_workload_reference",
})
CORE_TOOL_NAMES = frozenset({
"get_state_summary",
@ -61,11 +69,17 @@ class HubCoreMCPServer:
token_provider: Callable[[], str] | None = None,
require_credentials: bool = False,
trailing_slash: bool = True,
backend_profile: str = "embedded",
) -> None:
if backend_profile not in {"embedded", "runtime"}:
raise ValueError("unknown MCP backend profile")
if token_provider is not None or require_credentials:
require_https(api_base)
self.backend_profile = backend_profile
self.api_base = api_base.rstrip("/")
self.token_provider = token_provider
self.require_credentials = require_credentials
self.trailing_slash = trailing_slash
self.trailing_slash = False if backend_profile == "runtime" else trailing_slash
self.mcp = FastMCP(
name=name,
instructions=instructions or "Generic FOS hub MCP server.",
@ -94,6 +108,8 @@ class HubCoreMCPServer:
def register_core_tools(self, *, exclude: frozenset[str] | None = None) -> None:
excluded = exclude or frozenset()
if self.backend_profile == "runtime":
excluded = excluded | (CORE_TOOL_NAMES - RUNTIME_TOOL_NAMES)
register = lambda name: self._register_tool(name, excluded) # noqa: E731
@register("get_state_summary")
@ -348,7 +364,7 @@ class HubCoreMCPServer:
return self._json(
self._get(
"/ports/projections/repository-navigation/"
f"facets/{facet_kind}/{facet_value}",
f"facets/{self._segment(facet_kind)}/{self._segment(facet_value)}",
{"cursor": cursor, "limit": limit},
)
)
@ -548,13 +564,19 @@ class HubCoreMCPServer:
headers = {}
if self.token_provider is not None:
token = self.token_provider()
if not token or any(c.isspace() for c in token):
if not isinstance(token, str) or not token or len(token) > 16384 or not token.isascii() or any(c.isspace() for c in token):
raise ValueError("current invocation has no Hub credential")
headers["Authorization"] = f"Bearer {token}"
elif self.require_credentials:
raise ValueError("MCP host must provide a current Hub credential")
return httpx.Client(base_url=self.api_base, timeout=30.0,
headers=headers, follow_redirects=not bool(headers))
headers=headers, follow_redirects=not bool(headers), trust_env=False)
@staticmethod
def _segment(value: str) -> str:
if not value or value in {".", ".."} or any(c in value for c in "/\\?#%") or any(ord(c) < 32 for c in value):
raise ValueError("invalid route segment")
return quote(value, safe="")
@staticmethod
def _clean(data: dict[str, Any]) -> dict[str, Any]: