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

@ -29,6 +29,6 @@ inventory-check:
package-check:
$(UV) build --out-dir dist/ci
@set -eu; set -- dist/ci/*.whl; test "$$#" -eq 1; $(UV) run --no-project --with "$$1" python -I tools/check_installed_package.py
@set -eu; set -- dist/ci/*.whl; test "$$#" -eq 1; $(UV) run --no-project --isolated --refresh-package hub-core --with "$$1" python -I tools/check_installed_package.py
ci-check: test inventory-check package-check

View file

@ -125,3 +125,8 @@ are tracked in `HUB-WP-0006`.
API/MCP/migration commands, and a locked non-root OCI image.
Domain-specific MCP tools follow in each hub package.
MCP access enforcement and the standalone four-tool runtime profile are described
in the [MCP composition contract](docs/owner-access-integration.md#mcp-caller-composition-and-backend-mapping).
Production stdio requires an explicitly projected caller credential; network
MCP requires an authenticated host with per-invocation Hub credentials.

View file

@ -120,10 +120,13 @@ MCP hosts may supply `token_provider`, a callable resolving a **Hub-audience**
credential from the current invocation, with `require_credentials=True`. Credentials
are never retained on the server; requests do not follow redirects when carrying
one. `trailing_slash=False` targets the standalone runtime's native paths. The
standalone production MCP has no human flow/provider yet and fails closed.
standalone production MCP supports only explicit single-principal stdio credentials;
network transports require an authenticated host composition. The standalone
profile advertises four mapped runtime tools; legacy operations remain embedded-only.
Many legacy tools target APIs the standalone Hub does not implement. They remain
unsupported, not silently translated or authorized. Cross-audience delegation,
browser PKCE sessions/logout and real MCP root login remain open.
unsupported, not silently translated or authorized. Cross-audience delegation
remains open. Browser PKCE sessions/logout are implemented locally; live browser/MCP caller
admission remains open. See the [MCP composition contract](owner-access-integration.md#mcp-caller-composition-and-backend-mapping).
## Remaining release gates

View file

@ -51,7 +51,8 @@ harness reports only the implemented profile above.
`make ci-check` runs the test suite, checks the reviewed access inventory for
source drift, builds distributions and validates an installed wheel outside the
checkout's import path. Forgejo runs these gates for `main` pushes and manual
checkout's import path. The wheel check uses a fresh environment and refreshes
the Hub package so rebuilding the same version cannot reuse an older installation. Forgejo runs these gates for `main` pushes and manual
runs, using the full commit SHA and a unique temporary checkout. CI installs the
locked development and runtime dependencies first. Individual gates are
`make test`, `make inventory-check` and `make package-check`.

View file

@ -152,3 +152,56 @@ query strings are cleared before response-time application access logging, but
reverse proxies and tracing collectors must independently suppress callback
queries, cookies and authorization headers. No public listener is enabled by
these source changes.
## MCP caller composition and backend mapping
The standalone CLI now registers only four tools with equivalent runtime APIs:
| MCP tool | Runtime GET route |
| --- | --- |
| `query_repository_navigation` | `/ports/projections/repository-navigation/repositories` |
| `get_repository_navigation_facet` | `/ports/projections/repository-navigation/facets/{facet_kind}/{facet_value}` |
| `query_workloads` | `/ports/projections/workloads` |
| `resolve_workload_reference` | `/ports/projections/workloads/resolve` |
These use exact paths without redirect-based slash normalization. The other 27
generic tools require embedded host APIs: state/domain orientation, message
read/write/reply, capabilities/requests, repo/DOI operations, service/TPSC and
legacy progress operations. They remain available in the SDK's default
`backend_profile="embedded"`; they are not advertised by the standalone runtime
profile. Native message/event commands are not equivalent replacements for legacy
thread, recipient, author or event semantics. The inventory records each tool's
backend profiles. Host-specific routing and caller admission remain T04/T05 work.
For a **private, single-principal stdio process**, an operator can project an
already admitted Hub-audience credential and run:
```sh
HUB_CORE_ENV=production hub-core mcp --transport stdio \
--api-base https://hub.example --token-file /run/secrets/hub-mcp-caller
```
The file is reread for every request, with bounded size and sanitized errors.
Missing, invalid or expired credentials cannot fall back to anonymous/shared-root
access. Expiry, issuer, audience and current grants are checked by the Hub API.
The CLI neither obtains nor refreshes credentials; the projecting owner controls
rotation. This example is a composition interface, not an issued grant or live
acceptance. Do not share this process between principals or use an operator/root
token as an automated workload identity.
Enforced CLI network transports refuse startup: a shared MCP endpoint needs a
host that authenticates callers. `--token-file` is rejected for every network
transport, including development. The host SDK composition supplies
`token_provider=current_invocation_hub_credential`, `require_credentials=True`,
and an explicit backend profile. The provider must resolve the authenticated
invocation's **Hub-audience** credential; forwarding an MCP-audience token or
putting a static root token in the callback is not an admitted composition.
The SDK does not implement token exchange, delegation or host authentication.
Authenticated outbound requests require non-local HTTPS, never follow redirects,
and ignore proxy environment variables. Tools never accept credentials as model
arguments. Facet path segments reject traversal/separator/query injection.
Tests exercise actual FastMCP tool invocation with distinct concurrent caller
contexts, file rotation/removal, route-catalog admission for all four runtime
tools, enforced backend denial and CLI transport restrictions. They do not claim
that an external MCP consumer or workload has been admitted.

View file

@ -1203,14 +1203,17 @@
"tool": "accept_capability_request",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 232,
"line": 248,
"target_calls": [
{
"method": "POST",
"path_expression": "f'/capability-requests/{request_id}/accept/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:append_progress",
@ -1218,14 +1221,17 @@
"tool": "append_progress",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 487,
"line": 503,
"target_calls": [
{
"method": "POST",
"path_expression": "'/progress/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:check_repo_doi",
@ -1233,14 +1239,17 @@
"tool": "check_repo_doi",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 393,
"line": 409,
"target_calls": [
{
"method": "GET",
"path_expression": "f'/repos/{repo_slug}/doi/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_alerts",
@ -1248,14 +1257,17 @@
"tool": "get_alerts",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 474,
"line": 490,
"target_calls": [
{
"method": "GET",
"path_expression": "'/progress/alerts/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_capability_request",
@ -1263,14 +1275,17 @@
"tool": "get_capability_request",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 274,
"line": 290,
"target_calls": [
{
"method": "GET",
"path_expression": "f'/capability-requests/{request_id}/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_doi_summary",
@ -1278,14 +1293,17 @@
"tool": "get_doi_summary",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 402,
"line": 418,
"target_calls": [
{
"method": "GET",
"path_expression": "'/repos/doi/summary/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_domain",
@ -1293,14 +1311,17 @@
"tool": "get_domain",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 112,
"line": 128,
"target_calls": [
{
"method": "GET",
"path_expression": "f'/domains/{domain_slug}/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_domain_summary",
@ -1308,14 +1329,17 @@
"tool": "get_domain_summary",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 108,
"line": 124,
"target_calls": [
{
"method": "GET",
"path_expression": "f'/domains/{domain_slug}/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_gdpr_report",
@ -1323,14 +1347,17 @@
"tool": "get_gdpr_report",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 457,
"line": 473,
"target_calls": [
{
"method": "GET",
"path_expression": "'/tpsc/report/gdpr/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_messages",
@ -1338,14 +1365,17 @@
"tool": "get_messages",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 137,
"line": 153,
"target_calls": [
{
"method": "GET",
"path_expression": "'/messages/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_repository_navigation_facet",
@ -1353,14 +1383,18 @@
"tool": "get_repository_navigation_facet",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 341,
"line": 357,
"target_calls": [
{
"method": "GET",
"path_expression": "f'/ports/projections/repository-navigation/facets/{facet_kind}/{facet_value}'"
"path_expression": "f'/ports/projections/repository-navigation/facets/{self._segment(facet_kind)}/{self._segment(facet_value)}'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded",
"runtime"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_risks",
@ -1368,14 +1402,17 @@
"tool": "get_risks",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 461,
"line": 477,
"target_calls": [
{
"method": "GET",
"path_expression": "'/progress/risks/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:get_state_summary",
@ -1383,14 +1420,17 @@
"tool": "get_state_summary",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 100,
"line": 116,
"target_calls": [
{
"method": "GET",
"path_expression": "'/state/summary/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:ingest_tpsc_tool",
@ -1398,14 +1438,17 @@
"tool": "ingest_tpsc_tool",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 448,
"line": 464,
"target_calls": [
{
"method": "POST",
"path_expression": "'/tpsc/ingest/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:list_capabilities",
@ -1413,14 +1456,17 @@
"tool": "list_capabilities",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 192,
"line": 208,
"target_calls": [
{
"method": "GET",
"path_expression": "'/capability-catalog/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:list_capability_requests",
@ -1428,14 +1474,17 @@
"tool": "list_capability_requests",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 261,
"line": 277,
"target_calls": [
{
"method": "GET",
"path_expression": "'/capability-requests/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:list_domain_repos",
@ -1443,14 +1492,17 @@
"tool": "list_domain_repos",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 309,
"line": 325,
"target_calls": [
{
"method": "GET",
"path_expression": "'/repos/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:list_domains",
@ -1458,14 +1510,17 @@
"tool": "list_domains",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 104,
"line": 120,
"target_calls": [
{
"method": "GET",
"path_expression": "'/domains/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:list_services",
@ -1473,14 +1528,17 @@
"tool": "list_services",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 431,
"line": 447,
"target_calls": [
{
"method": "GET",
"path_expression": "'/tpsc/catalog/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:mark_message_read",
@ -1488,14 +1546,17 @@
"tool": "mark_message_read",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 156,
"line": 172,
"target_calls": [
{
"method": "PATCH",
"path_expression": "f'/messages/{message_id}/read/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:query_repository_navigation",
@ -1503,14 +1564,18 @@
"tool": "query_repository_navigation",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 313,
"line": 329,
"target_calls": [
{
"method": "GET",
"path_expression": "'/ports/projections/repository-navigation/repositories'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded",
"runtime"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:query_workloads",
@ -1518,14 +1583,18 @@
"tool": "query_workloads",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 357,
"line": 373,
"target_calls": [
{
"method": "GET",
"path_expression": "'/ports/projections/workloads'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded",
"runtime"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:register_capability",
@ -1533,14 +1602,17 @@
"tool": "register_capability",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 169,
"line": 185,
"target_calls": [
{
"method": "POST",
"path_expression": "'/capability-catalog/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:register_repo",
@ -1548,14 +1620,17 @@
"tool": "register_repo",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 278,
"line": 294,
"target_calls": [
{
"method": "POST",
"path_expression": "'/repos/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:register_service",
@ -1563,14 +1638,17 @@
"tool": "register_service",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 406,
"line": 422,
"target_calls": [
{
"method": "POST",
"path_expression": "'/tpsc/catalog/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:reply_to_message",
@ -1578,14 +1656,17 @@
"tool": "reply_to_message",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 160,
"line": 176,
"target_calls": [
{
"method": "POST",
"path_expression": "f'/messages/{message_id}/reply/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:request_capability",
@ -1593,14 +1674,17 @@
"tool": "request_capability",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 205,
"line": 221,
"target_calls": [
{
"method": "POST",
"path_expression": "'/capability-requests/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:resolve_workload_reference",
@ -1608,14 +1692,18 @@
"tool": "resolve_workload_reference",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 379,
"line": 395,
"target_calls": [
{
"method": "GET",
"path_expression": "'/ports/projections/workloads/resolve'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded",
"runtime"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:send_message",
@ -1623,14 +1711,17 @@
"tool": "send_message",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 116,
"line": 132,
"target_calls": [
{
"method": "POST",
"path_expression": "'/messages/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:update_capability_request_status",
@ -1638,14 +1729,17 @@
"tool": "update_capability_request_status",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 248,
"line": 264,
"target_calls": [
{
"method": "PATCH",
"path_expression": "f'/capability-requests/{request_id}/status/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "mcp:update_repo_path",
@ -1653,14 +1747,17 @@
"tool": "update_repo_path",
"profile": "mcp-client",
"source": "hub_core/mcp/server.py",
"line": 303,
"line": 319,
"target_calls": [
{
"method": "POST",
"path_expression": "f'/repos/{repo_slug}/paths/'"
}
],
"current_gate": "per-invocation token provider available; host adoption required"
"backend_profiles": [
"embedded"
],
"current_gate": "per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required"
},
{
"id": "sdk:create_capability_catalog_router:GET:/capability-catalog/",

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

View file

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

View file

@ -0,0 +1,136 @@
import asyncio
import json
from contextvars import ContextVar
import httpx
import pytest
from fastmcp import FastMCP
from fastapi.testclient import TestClient
from hub_core.mcp import CORE_TOOL_NAMES, RUNTIME_TOOL_NAMES, HubCoreMCPServer
from hub_core.mcp.credentials import StdioTokenFile
from hub_core.runtime.cli import _run_mcp
from test_access_boundary import Owners, runtime
def test_runtime_profile_lists_only_equivalent_tools_even_when_attached():
server = HubCoreMCPServer(name='runtime', api_base='https://hub.example', backend_profile='runtime')
assert {t.name for t in asyncio.run(server.mcp.list_tools())} == RUNTIME_TOOL_NAMES
assert len(CORE_TOOL_NAMES - RUNTIME_TOOL_NAMES) == 27
server.attach_to(FastMCP('host'))
assert {t.name for t in asyncio.run(server.mcp.list_tools())} == RUNTIME_TOOL_NAMES
assert not server.trailing_slash
def test_actual_tool_calls_preserve_concurrent_caller_credentials(monkeypatch):
credential = ContextVar('caller')
server = HubCoreMCPServer(name='runtime', api_base='https://hub.example', backend_profile='runtime',
token_provider=credential.get, require_credentials=True)
seen = []
original = httpx.Client
def handle(request):
seen.append((request.headers['authorization'], request.url.path))
return httpx.Response(200, json={'ok': True})
def client(**kwargs):
assert kwargs['trust_env'] is False
assert kwargs['follow_redirects'] is False
return original(**kwargs, transport=httpx.MockTransport(handle))
monkeypatch.setattr('hub_core.mcp.server.httpx.Client', client)
async def invoke(token):
credential.set(token)
await asyncio.sleep(0)
await server.mcp.call_tool('query_workloads', {})
async def run():
await asyncio.gather(invoke('caller-a'), invoke('caller-b'))
asyncio.run(run())
assert sorted(seen) == [('Bearer caller-a','/ports/projections/workloads'),
('Bearer caller-b','/ports/projections/workloads')]
def test_rotating_stdio_credential_reaches_real_enforced_runtime(tmp_path, monkeypatch):
path = tmp_path / 'credential'
path.write_text('verified-root\n')
owners = Owners()
seen = []
server = HubCoreMCPServer(name='runtime', api_base='https://hub.example', backend_profile='runtime',
token_provider=StdioTokenFile(path), require_credentials=True)
original = httpx.Client
with TestClient(runtime(owners)) as target:
def handle(request):
response = target.get(request.url.path, headers={'Authorization':request.headers['authorization']})
seen.append(response.status_code)
return httpx.Response(response.status_code, content=response.content)
monkeypatch.setattr('hub_core.mcp.server.httpx.Client',
lambda **kw: original(**kw, transport=httpx.MockTransport(handle)))
asyncio.run(server.mcp.call_tool('query_workloads', {}))
assert owners.requests[-1].actor.subject == 'immutable-root'
# Absent workload projection returns 503 after successful authorization.
path.write_text('invalid-caller')
result = asyncio.run(server.mcp.call_tool('query_workloads', {}))
assert seen[-1] == 401
assert '401' in str(result)
path.unlink()
before = len(seen)
result = asyncio.run(server.mcp.call_tool('query_workloads', {}))
assert len(seen) == before
assert 'Request failed' in str(result)
@pytest.mark.parametrize('value', ['', 'two tokens', 'x'*16386, 'é', 'x'*16384+'\nextra'])
def test_credential_file_rejects_invalid_values(tmp_path, value):
path = tmp_path / 'credential'
path.write_text(value)
with pytest.raises((ValueError, UnicodeError)):
StdioTokenFile(path)()
def test_cli_enforcement_requires_private_stdio(monkeypatch, tmp_path):
monkeypatch.setenv('HUB_CORE_ENV','production')
for transport, path in [('http',None), ('http',tmp_path/'token'), ('stdio',None)]:
with pytest.raises(SystemExit):
_run_mcp('127.0.0.1',8011,transport,'https://hub.example',path)
captured = []
monkeypatch.setattr(FastMCP, 'run', lambda self, **kw: captured.append(kw))
_run_mcp('127.0.0.1',8011,'stdio','https://hub.example',tmp_path/'token')
assert captured == [{'transport':'stdio'}]
@pytest.mark.parametrize('url',['http://hub.example','https://user:password@hub.example','https://localhost'])
def test_credential_transport_requires_trusted_https(url):
with pytest.raises(ValueError):
HubCoreMCPServer(name='bad',api_base=url,token_provider=lambda:'token')
@pytest.mark.parametrize('segment',['..','../docs','a/b','a?b','a#b','%2e%2e','a\\b'])
def test_facet_arguments_cannot_change_route(segment):
with pytest.raises(ValueError):
HubCoreMCPServer._segment(segment)
def test_all_runtime_tools_target_admitted_runtime_get_routes(monkeypatch):
from starlette.routing import Match
from hub_core.security.boundary import iter_routes, route_key
from pathlib import Path
catalog = json.loads(Path('hub_core/security/routes.json').read_text())['routes']
app = runtime(Owners())
server = HubCoreMCPServer(name='runtime',api_base='https://hub.example',backend_profile='runtime',
token_provider=lambda:'caller',require_credentials=True)
original = httpx.Client
seen = []
def handle(request):
scope = {'type':'http','path':request.url.path,'root_path':'','method':request.method}
route = next(r for r in iter_routes(app) if r.matches(scope)[0] == Match.FULL)
assert route_key(route,request.method) in catalog
seen.append(request.url.path)
return httpx.Response(200,json={'ok':True})
monkeypatch.setattr('hub_core.mcp.server.httpx.Client',
lambda **kw: original(**kw,transport=httpx.MockTransport(handle)))
calls = {'query_repository_navigation':{},
'get_repository_navigation_facet':{'facet_kind':'category','facet_value':'tools'},
'query_workloads':{}, 'resolve_workload_reference':{'rapp_id':'rapp:test','name':'test'}}
async def run():
for name, arguments in calls.items():
await server.mcp.call_tool(name,arguments)
asyncio.run(run())
assert set(calls) == RUNTIME_TOOL_NAMES
assert len(seen) == 4

View file

@ -19,6 +19,8 @@ def discover(root):
from hub_core.runtime.inbox_projection import create_inbox_projection_router
from hub_core.security.browser import create_browser_router
from hub_core.mcp import RUNTIME_TOOL_NAMES
rows = []
def routes(router):
@ -100,7 +102,8 @@ def discover(root):
calls.append(dict(method=call.func.attr[1:].upper(), path_expression=ast.unparse(call.args[0])))
rows.append(dict(id=f'mcp:{name}', kind='mcp', tool=name, profile='mcp-client',
source=str(path.relative_to(root)), line=node.lineno,
target_calls=calls, current_gate='per-invocation token provider available; host adoption required'))
target_calls=calls, backend_profiles=['embedded', 'runtime'] if name in RUNTIME_TOOL_NAMES else ['embedded'],
current_gate='per-invocation credential provider available; enforced runtime requires stdio credential file; embedded host admission required'))
assert expected == {r['tool'] for r in rows if r['kind'] == 'mcp'}
assert len(rows) == len({r['id'] for r in rows}), 'Duplicate surface identity'
return sorted(rows, key=lambda r:r['id'])

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Check an installed wheel outside the checkout's import path (use python -I).
Run via: uv run --no-project --with dist/<wheel> python -I tools/check_installed_package.py
Run via: uv run --no-project --isolated --refresh-package hub-core --with dist/<wheel> python -I tools/check_installed_package.py
No startup, owner requests, credentials or database connection are needed.
"""
import json
@ -9,6 +9,7 @@ from importlib.resources import files
from pathlib import Path
import hub_core
from hub_core.mcp.credentials import StdioTokenFile
from hub_core.conformance import ConformanceHarness
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
@ -22,6 +23,7 @@ def main():
installed = Path(hub_core.__file__).resolve()
if installed.is_relative_to(checkout):
raise RuntimeError('package smoke imported checkout instead of installed wheel')
StdioTokenFile(Path("/not-read-by-package-check"))
catalog = json.loads(files('hub_core.security').joinpath('routes.json').read_text())['routes']
app = create_app(settings=RuntimeSettings(environment='test', access_mode='enforce'))
routes = [*iter_routes(app), *iter_routes(create_inbox_projection_router())]

View file

@ -347,6 +347,29 @@ sync and workflow YAML/shell syntax checks also pass.
These are local source/release gates, not live platform or remote CI acceptance;
T01–T04 remain `progress` and T06 remains open.
## MCP caller continuation — 2026-09-28
The standalone MCP runtime profile now advertises the four tools whose GET
routes exist in the runtime. The other 27 remain explicit embedded-host tools;
no legacy message/progress semantics are silently translated to native ports.
Added rotating, bounded single-principal stdio credential-file composition.
Enforced CLI network transports refuse startup pending an authenticated host;
credential files cannot be attached to a shared network listener. SDK hosts
retain per-invocation providers. Credential-bearing requests require HTTPS,
disable redirects and ignore proxy environment variables. Facet arguments cannot
inject paths or queries. Inventory rows record each tool's backend profiles.
Actual tool-invocation tests cover concurrent caller isolation, credential
rotation/removal, all four runtime route mappings and enforced backend denial.
See the [MCP composition contract](../docs/owner-access-integration.md#mcp-caller-composition-and-backend-mapping).
Validation: **350 tests pass** (one optional owner-source module skipped);
inventory drift, distribution builds and isolated installed-wheel checks pass.
The package gate caught reuse of an older same-version uv installation; it now
forces a fresh environment and refreshes the Hub package, and the rerun passed.
No credential was issued or retrieved and no consumer was switched. T04 remains
`progress`; host authentication/exchange, real caller admission and T05 remain open.
## Acceptance checkpoints
- [x] Architecture/source/runtime review captured; new implementation owner is hub-core