Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/service/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ provider. The MCP tool catalog and API-specific authorization do not change.

## Available tools

The external catalog contains 25 tools: 14 read-only tools for caller-bound
The external catalog contains 26 tools: 15 read-only tools for caller-bound
health, profile, pool, resource, workflow, application, and
credential-metadata inspection, plus four workflow actions, one
profile-setting action, one credential action, four app lifecycle actions,
Expand Down Expand Up @@ -435,7 +435,7 @@ authentication. Its token needs `mcp:Access`, `profile:Read`, and
bazel run //test/oetf:run -- --env <mcp-enabled-env> --tags mcp
```

The smoke test rejects unauthenticated access, verifies the exact 25-tool
The smoke test rejects unauthenticated access, verifies the exact 26-tool
catalog, compares the profile projection with Core, checks caller-bound
health, and validates a small workflow through Gateway → MCP → Gateway → Core.
A successful validation does not enqueue compute or create a workflow row.
Expand Down
3 changes: 2 additions & 1 deletion test/oetf/data/oetf.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ environments:
cluster_name: osmo
mode: cpu
pool: default
exclude_tags: [auth]
# kind ships no JWT issuer and keeps MCP disabled.
exclude_tags: [auth, mcp]
7 changes: 4 additions & 3 deletions test/smoke/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ oetf_smoke_test(
tags = ["api", "kind"],
)

# Public KIND has no JWT issuer and keeps MCP disabled. Run this target against
# an MCP-enabled deployment with token authentication.
# Tagged by what it needs deployed, not by how it authenticates. The previous
# `auth` tag meant the kind environment's exclude_tags dropped this suite
# everywhere it ran, so it never executed and its expectations went stale.
oetf_smoke_test(
name = "mcp-checks",
src = "mcp_checks.py",
tags = ["api", "auth", "mcp"],
tags = ["api", "mcp"],
)

# auth-checks stays internal — public quick-start chart ships no JWT issuer
Expand Down
124 changes: 116 additions & 8 deletions test/smoke/mcp_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"osmo_list_apps",
"osmo_list_credentials",
"osmo_list_resources",
"osmo_list_tasks",
"osmo_list_workflows",
"osmo_search_pools",
"osmo_restart_workflow",
Expand Down Expand Up @@ -103,17 +104,77 @@ def _jsonrpc_result(self, response, request_id):
self.fail("MCP returned an invalid JSON-RPC result.")
return result

def _base_url(self):
return self.config.url.rstrip("/")

def _protected_resource_metadata(self):
"""Fetch the public RFC 9728 document. No authentication required."""
response = requests.get(
f"{self._base_url()}/.well-known/oauth-protected-resource/mcp",
timeout=10,
allow_redirects=False,
)
self.assertEqual(response.status_code, 200, response.text)
return response.json()

def _is_proxy_mode(self):
"""Whether FastMCP, rather than the Gateway, authenticates /mcp.

In proxy mode the deployment advertises itself as the authorization
server; in direct mode it advertises the upstream identity provider.
"""
servers = self._protected_resource_metadata().get(
"authorization_servers"
) or []
return any(
str(server).startswith(self._base_url()) for server in servers
)

def _require_mcp_authentication(self):
"""Skip when the OETF token cannot authenticate to this deployment.

In proxy mode FastMCP verifies caller tokens against the identity
provider, so the OSMO-issued OETF token is rejected. Supplying
OSMO_MCP_ACCESS_TOKEN lets these checks run there too.
"""
if os.environ.get("OSMO_MCP_ACCESS_TOKEN"):
return
if self._is_proxy_mode():
self.skipTest(
"MCP is deployed in OIDC-proxy mode, where FastMCP verifies "
"caller tokens against the identity provider. The OETF token "
"is issued by OSMO and cannot authenticate to /mcp. Set "
"OSMO_MCP_ACCESS_TOKEN to an identity-provider access token "
"for this resource to run the authenticated checks."
)

def _mcp_request(self, request_id, method, params):
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params,
}
override_token = os.environ.get("OSMO_MCP_ACCESS_TOKEN")
if override_token:
headers = dict(_MCP_ACCEPT_HEADERS)
headers["Authorization"] = f"Bearer {override_token}"
http_response = requests.post(
f"{self._base_url()}/mcp",
headers=headers,
json=payload,
timeout=30,
allow_redirects=False,
)
self.assertEqual(
http_response.status_code, 200, http_response.text
)
return self._jsonrpc_result(http_response.json(), request_id)
response = self.service_client.request(
method=RequestMethod.POST,
endpoint="mcp",
headers=dict(_MCP_ACCEPT_HEADERS),
payload={
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params,
},
payload=payload,
version_header=False,
)
return self._jsonrpc_result(response, request_id)
Expand All @@ -135,8 +196,13 @@ def _call_tool(self, request_id, name, arguments):
self.fail(f"MCP tool {name} returned an unsuccessful result.")
return structured_content

def test_catalog_profile_and_credential_parity(self):
base_url = self.config.url.rstrip("/")
def test_public_discovery_surface(self):
"""The unauthenticated surface clients rely on to bootstrap OAuth.

Runs in both authentication modes: it needs no caller token, which is
the point -- a client discovers how to authenticate before it can.
"""
base_url = self._base_url()
unauthenticated_response = requests.post(
f"{base_url}/mcp",
headers=dict(_MCP_ACCEPT_HEADERS),
Expand All @@ -156,6 +222,47 @@ def test_catalog_profile_and_credential_parity(self):
).startswith("Bearer resource_metadata=")
)

metadata = self._protected_resource_metadata()
self.assertEqual(metadata.get("resource"), f"{base_url}/mcp")
self.assertTrue(
metadata.get("authorization_servers"),
"protected-resource metadata advertises no authorization server.",
)

if not self._is_proxy_mode():
return

# FastMCP owns the OAuth surface, so it must advertise its endpoints
# under /mcp rather than on the shared Gateway root.
authorization_metadata = requests.get(
f"{base_url}/.well-known/oauth-authorization-server/mcp",
timeout=10,
allow_redirects=False,
)
self.assertEqual(
authorization_metadata.status_code,
200,
authorization_metadata.text,
)
self.assertEqual(
authorization_metadata.json().get("authorization_endpoint"),
f"{base_url}/mcp/authorize",
)

# The /mcp prefix route publishes the container root, so the health
# endpoints are carved out ahead of it.
for path in ("/mcp/health", "/mcp/health/live"):
health = requests.get(
f"{base_url}{path}", timeout=10, allow_redirects=False
)
self.assertEqual(
health.status_code,
404,
f"{path} is reachable from the internet.",
)

def test_catalog_profile_and_credential_parity(self):
self._require_mcp_authentication()
catalog_result = self._mcp_request(1, "tools/list", {})
catalog_tools = catalog_result.get("tools")
if not isinstance(catalog_tools, list) or not all(
Expand Down Expand Up @@ -280,6 +387,7 @@ def test_catalog_profile_and_credential_parity(self):
)

def test_workflow_validation_round_trip(self):
self._require_mcp_authentication()
pool = self.config.pool
if not pool:
self.fail("OETF_POOL must select a workflow validation pool.")
Expand Down
Loading