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]:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ def build_parser(settings: RuntimeSettings | None = None) -> argparse.ArgumentPa
|
|||
mcp.add_argument("--port", type=int, default=resolved.mcp_port)
|
||||
mcp.add_argument("--transport", default=resolved.mcp_transport)
|
||||
mcp.add_argument("--api-base", default=resolved.api_base)
|
||||
mcp.add_argument("--token-file", type=Path, help="projected Hub credential for single-principal stdio only")
|
||||
|
||||
migrate = commands.add_parser("migrate", help="Run packaged Alembic migrations")
|
||||
migrate.add_argument("revision", nargs="?", default="head")
|
||||
|
|
@ -87,7 +88,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
_run_api(args.host, args.port)
|
||||
return 0
|
||||
if args.command == "mcp":
|
||||
_run_mcp(args.host, args.port, args.transport, args.api_base)
|
||||
_run_mcp(args.host, args.port, args.transport, args.api_base, args.token_file)
|
||||
return 0
|
||||
if args.command == "migrate":
|
||||
if not args.database_url:
|
||||
|
|
@ -109,11 +110,22 @@ def _run_api(host: str, port: int) -> None:
|
|||
uvicorn.run("hub_core.runtime.app:app", host=host, port=port)
|
||||
|
||||
|
||||
def _run_mcp(host: str, port: int, transport: str, api_base: str) -> None:
|
||||
def _run_mcp(host: str, port: int, transport: str, api_base: str,
|
||||
token_file: Path | None = None) -> None:
|
||||
from hub_core.mcp.credentials import StdioTokenFile
|
||||
enforced = RuntimeSettings.from_env().enforce_access
|
||||
if token_file is not None and transport != "stdio":
|
||||
raise SystemExit("--token-file is restricted to single-principal stdio")
|
||||
if enforced and (transport != "stdio" or token_file is None):
|
||||
raise SystemExit("enforced MCP requires stdio and --token-file; network MCP needs an authenticated host composition")
|
||||
provider = StdioTokenFile(token_file) if token_file is not None else None
|
||||
server = HubCoreMCPServer(name="hub-core", api_base=api_base,
|
||||
require_credentials=RuntimeSettings.from_env().enforce_access,
|
||||
trailing_slash=False)
|
||||
server.mcp.run(transport=transport, host=host, port=port)
|
||||
token_provider=provider, require_credentials=enforced or provider is not None,
|
||||
backend_profile="runtime")
|
||||
if transport == "stdio":
|
||||
server.mcp.run(transport="stdio")
|
||||
else:
|
||||
server.mcp.run(transport=transport, host=host, port=port)
|
||||
|
||||
|
||||
def _run_migrations(database_url: str, revision: str) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue