diff --git a/Makefile b/Makefile index e9e608c..03a47de 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 9df507d..d25c35b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/access-profile-v1.md b/docs/access-profile-v1.md index 1b337b3..4bdec80 100644 --- a/docs/access-profile-v1.md +++ b/docs/access-profile-v1.md @@ -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 diff --git a/docs/conformance.md b/docs/conformance.md index 85c7835..5c43916 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -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`. diff --git a/docs/owner-access-integration.md b/docs/owner-access-integration.md index f1219fb..75242bc 100644 --- a/docs/owner-access-integration.md +++ b/docs/owner-access-integration.md @@ -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. diff --git a/docs/platform-access-inventory.json b/docs/platform-access-inventory.json index 69fe7d4..cac384c 100644 --- a/docs/platform-access-inventory.json +++ b/docs/platform-access-inventory.json @@ -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/", diff --git a/hub_core/mcp/__init__.py b/hub_core/mcp/__init__.py index 59c6edf..75ca590 100644 --- a/hub_core/mcp/__init__.py +++ b/hub_core/mcp/__init__.py @@ -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"] diff --git a/hub_core/mcp/credentials.py b/hub_core/mcp/credentials.py new file mode 100644 index 0000000..f67c753 --- /dev/null +++ b/hub_core/mcp/credentials.py @@ -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 diff --git a/hub_core/mcp/server.py b/hub_core/mcp/server.py index de0a293..981a51e 100644 --- a/hub_core/mcp/server.py +++ b/hub_core/mcp/server.py @@ -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]: diff --git a/hub_core/runtime/cli.py b/hub_core/runtime/cli.py index 31972fc..2f1e265 100644 --- a/hub_core/runtime/cli.py +++ b/hub_core/runtime/cli.py @@ -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: diff --git a/tests/test_mcp_runtime_access.py b/tests/test_mcp_runtime_access.py new file mode 100644 index 0000000..d43ebf1 --- /dev/null +++ b/tests/test_mcp_runtime_access.py @@ -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 diff --git a/tools/build_access_inventory.py b/tools/build_access_inventory.py index aad7b1d..6d8a890 100644 --- a/tools/build_access_inventory.py +++ b/tools/build_access_inventory.py @@ -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']) diff --git a/tools/check_installed_package.py b/tools/check_installed_package.py index caa2df0..ae1e75d 100644 --- a/tools/check_installed_package.py +++ b/tools/check_installed_package.py @@ -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/ python -I tools/check_installed_package.py +Run via: uv run --no-project --isolated --refresh-package hub-core --with dist/ 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())] diff --git a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md index b88e777..deb351e 100644 --- a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md +++ b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md @@ -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