feat: compose authenticated stdio MCP with explicit runtime tool mapping
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
1ece969f59
commit
3c5cbfbafe
14 changed files with 465 additions and 81 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
27
hub_core/mcp/credentials.py
Normal file
27
hub_core/mcp/credentials.py
Normal 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
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue