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

@ -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]: