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
2
Makefile
2
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
136
tests/test_mcp_runtime_access.py
Normal file
136
tests/test_mcp_runtime_access.py
Normal 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
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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())]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue