feat(astrolabe): add dynamic MCP server configuration for testing
Replace static post-installation configuration with dynamic test-time configuration to support testing multiple MCP server deployments. Changes: - Remove static MCP server URL and OAuth client setup from post-installation - Add configure_astrolabe_for_mcp_server fixture (session-scoped) - Fixture dynamically configures: * Nextcloud system config (mcp_server_url, mcp_server_public_url) * OAuth client creation via occ oidc:create * Client credential storage (astrolabe_client_id, astrolabe_client_secret) - Update existing OAuth tests to use dynamic configuration - Add test_astrolabe_multi_server_integration.py with parametrized tests Benefits: - Test Astrolabe with mcp-oauth, mcp-keycloak, mcp-multi-user-basic - Each test configures for its specific MCP server - No static configuration conflicts between deployments - Cleaner post-installation (37 lines, down from 85) Test Results: - test_astrolabe_configuration_for_different_servers: PASSED (mcp-oauth, mcp-keycloak) - test_astrolabe_reconfiguration: PASSED 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring Astrolabe app for testing..."
|
||||
echo "Installing Astrolabe app for testing..."
|
||||
|
||||
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
||||
if [ -d /opt/apps/astrolabe ]; then
|
||||
@@ -30,55 +30,7 @@ else
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
fi
|
||||
|
||||
# Configure MCP server URLs in Nextcloud system config
|
||||
# - mcp_server_url: Internal URL for PHP app to call MCP server APIs (Docker internal network)
|
||||
# - mcp_server_public_url: Public URL for OAuth token audience (what browsers/MCP clients see)
|
||||
php /var/www/html/occ config:system:set mcp_server_url --value='http://mcp-oauth:8001'
|
||||
php /var/www/html/occ config:system:set mcp_server_public_url --value='http://localhost:8001'
|
||||
|
||||
# Create OAuth client for Astrolabe app
|
||||
# The resource_url MUST match what the MCP server expects as token audience
|
||||
# This allows tokens from this client to be validated by MCP server's UnifiedTokenVerifier
|
||||
MCP_CLIENT_ID="nextcloudMcpServerUIPublicClient"
|
||||
MCP_RESOURCE_URL="http://localhost:8001"
|
||||
MCP_REDIRECT_URI="http://localhost:8080/apps/astrolabe/oauth/callback"
|
||||
|
||||
echo "Configuring OAuth client for Astrolabe..."
|
||||
|
||||
# Check if client already exists
|
||||
if php /var/www/html/occ oidc:list 2>/dev/null | grep -q "$MCP_CLIENT_ID"; then
|
||||
echo "OAuth client $MCP_CLIENT_ID already exists, removing to recreate with correct settings..."
|
||||
php /var/www/html/occ oidc:remove "$MCP_CLIENT_ID" || true
|
||||
fi
|
||||
|
||||
# Create OAuth client with correct resource_url for MCP server audience
|
||||
echo "Creating OAuth confidential client with resource_url=$MCP_RESOURCE_URL"
|
||||
CLIENT_OUTPUT=$(php /var/www/html/occ oidc:create \
|
||||
"Astrolabe" \
|
||||
"$MCP_REDIRECT_URI" \
|
||||
--client_id="$MCP_CLIENT_ID" \
|
||||
--type=confidential \
|
||||
--flow=code \
|
||||
--token_type=jwt \
|
||||
--resource_url="$MCP_RESOURCE_URL" \
|
||||
--allowed_scopes="openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write")
|
||||
|
||||
echo "$CLIENT_OUTPUT"
|
||||
|
||||
# Extract client_secret from JSON output
|
||||
CLIENT_SECRET=$(echo "$CLIENT_OUTPUT" | php -r 'echo json_decode(file_get_contents("php://stdin"), true)["client_secret"] ?? "";')
|
||||
|
||||
if [ -n "$CLIENT_SECRET" ]; then
|
||||
echo "Configuring Astrolabe client secret in system config..."
|
||||
php /var/www/html/occ config:system:set astrolabe_client_secret --value="$CLIENT_SECRET"
|
||||
echo "✓ Client secret configured: ${CLIENT_SECRET:0:8}..."
|
||||
else
|
||||
echo "⚠ Warning: Could not extract client_secret from OIDC client creation"
|
||||
fi
|
||||
|
||||
# Configure OAuth client ID in system config
|
||||
echo "Configuring Astrolabe client ID in system config..."
|
||||
php /var/www/html/occ config:system:set astrolabe_client_id --value="$MCP_CLIENT_ID"
|
||||
echo "✓ Client ID configured: $MCP_CLIENT_ID"
|
||||
|
||||
echo "Astrolabe app installed and configured successfully"
|
||||
echo "✓ Astrolabe app installed successfully"
|
||||
echo ""
|
||||
echo "Note: MCP server configuration is managed dynamically during tests"
|
||||
echo " to support testing multiple MCP server deployments."
|
||||
|
||||
+228
-2
@@ -114,6 +114,7 @@ async def create_mcp_client_session(
|
||||
client_name: str = "MCP",
|
||||
elicitation_callback: Any = None,
|
||||
sampling_callback: Any = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Factory function to create an MCP client session with proper lifecycle management.
|
||||
@@ -135,6 +136,8 @@ async def create_mcp_client_session(
|
||||
Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData
|
||||
sampling_callback: Optional callback for handling sampling (LLM generation) requests.
|
||||
Should match signature: async def callback(context: RequestContext, params: CreateMessageRequestParams) -> CreateMessageResult | ErrorData
|
||||
headers: Optional custom headers (e.g., for BasicAuth). If both headers and token are provided,
|
||||
custom headers take precedence.
|
||||
|
||||
Yields:
|
||||
Initialized MCP ClientSession
|
||||
@@ -147,8 +150,9 @@ async def create_mcp_client_session(
|
||||
"""
|
||||
logger.info(f"Creating Streamable HTTP client for {client_name}")
|
||||
|
||||
# Prepare headers with OAuth token if provided
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
# Prepare headers - custom headers take precedence over token-based auth
|
||||
if headers is None:
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
|
||||
# Use native async with - Python ensures LIFO cleanup
|
||||
# Cleanup order will be: ClientSession.__aexit__ -> streamablehttp_client.__aexit__
|
||||
@@ -240,6 +244,32 @@ async def nc_mcp_oauth_client(
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_basic_auth_client(
|
||||
anyio_backend,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session with BasicAuth credentials.
|
||||
Connects to the multi-user BasicAuth MCP server on port 8003 with ENABLE_MULTI_USER_BASIC_AUTH=true.
|
||||
|
||||
Uses BasicAuth credentials for multi-user pass-through mode (ADR-020).
|
||||
Credentials are passed in Authorization header and forwarded to Nextcloud APIs.
|
||||
|
||||
Uses anyio pytest plugin for proper async fixture handling.
|
||||
"""
|
||||
import base64
|
||||
|
||||
credentials = base64.b64encode(b"admin:admin").decode("utf-8")
|
||||
auth_header = f"Basic {credentials}"
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8003/mcp",
|
||||
headers={"Authorization": auth_header},
|
||||
client_name="BasicAuth MCP (Multi-User)",
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_jwt_client(
|
||||
anyio_backend,
|
||||
@@ -3187,3 +3217,199 @@ async def nc_mcp_keycloak_client_no_custom_scopes(
|
||||
client_name="Keycloak No Custom Scopes MCP",
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# Astrolabe Dynamic Configuration Fixtures
|
||||
# ========================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def configure_astrolabe_for_mcp_server(nc_client):
|
||||
"""Configure Astrolabe app to connect to a specific MCP server.
|
||||
|
||||
This fixture dynamically configures the Astrolabe app's MCP server settings
|
||||
and OAuth client, allowing tests to verify integration with different MCP
|
||||
server deployments (mcp-oauth, mcp-keycloak, mcp-multi-user-basic, etc.).
|
||||
|
||||
Usage:
|
||||
async def test_my_integration(configure_astrolabe_for_mcp_server):
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-oauth:8001",
|
||||
mcp_server_public_url="http://localhost:8001"
|
||||
)
|
||||
# ... test Astrolabe integration ...
|
||||
|
||||
Args:
|
||||
nc_client: NextcloudClient fixture for occ command execution
|
||||
|
||||
Returns:
|
||||
Async function that accepts:
|
||||
- mcp_server_internal_url: Internal Docker URL for PHP app to call MCP APIs
|
||||
- mcp_server_public_url: Public URL for OAuth token audience validation
|
||||
- client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient")
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
async def _configure(
|
||||
mcp_server_internal_url: str,
|
||||
mcp_server_public_url: str,
|
||||
client_id: str = "nextcloudMcpServerUIPublicClient",
|
||||
) -> dict[str, str]:
|
||||
"""Configure Astrolabe for the specified MCP server.
|
||||
|
||||
Returns:
|
||||
Dict with client_id and client_secret
|
||||
"""
|
||||
logger.info(
|
||||
f"Configuring Astrolabe for MCP server: {mcp_server_internal_url} (public: {mcp_server_public_url})"
|
||||
)
|
||||
|
||||
# Configure MCP server URLs in Nextcloud system config
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"app",
|
||||
"php",
|
||||
"/var/www/html/occ",
|
||||
"config:system:set",
|
||||
"mcp_server_url",
|
||||
"--value",
|
||||
mcp_server_internal_url,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"app",
|
||||
"php",
|
||||
"/var/www/html/occ",
|
||||
"config:system:set",
|
||||
"mcp_server_public_url",
|
||||
"--value",
|
||||
mcp_server_public_url,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
logger.info("✓ MCP server URLs configured")
|
||||
|
||||
# Remove existing OAuth client if it exists
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"app",
|
||||
"php",
|
||||
"/var/www/html/occ",
|
||||
"oidc:remove",
|
||||
client_id,
|
||||
],
|
||||
check=False, # Don't fail if client doesn't exist
|
||||
capture_output=True,
|
||||
)
|
||||
logger.info(f"Removed existing OAuth client: {client_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create OAuth client for Astrolabe
|
||||
redirect_uri = "http://localhost:8080/apps/astrolabe/oauth/callback"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"app",
|
||||
"php",
|
||||
"/var/www/html/occ",
|
||||
"oidc:create",
|
||||
"Astrolabe",
|
||||
redirect_uri,
|
||||
"--client_id",
|
||||
client_id,
|
||||
"--type",
|
||||
"confidential",
|
||||
"--flow",
|
||||
"code",
|
||||
"--token_type",
|
||||
"jwt",
|
||||
"--resource_url",
|
||||
mcp_server_public_url,
|
||||
"--allowed_scopes",
|
||||
"openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Parse client_secret from JSON output
|
||||
client_output = json.loads(result.stdout.strip())
|
||||
client_secret = client_output.get("client_secret")
|
||||
|
||||
if not client_secret:
|
||||
raise ValueError(
|
||||
"Failed to extract client_secret from OAuth client creation"
|
||||
)
|
||||
|
||||
logger.info(f"✓ OAuth client created: {client_id}")
|
||||
|
||||
# Store client credentials in Nextcloud system config
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"app",
|
||||
"php",
|
||||
"/var/www/html/occ",
|
||||
"config:system:set",
|
||||
"astrolabe_client_id",
|
||||
"--value",
|
||||
client_id,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"app",
|
||||
"php",
|
||||
"/var/www/html/occ",
|
||||
"config:system:set",
|
||||
"astrolabe_client_secret",
|
||||
"--value",
|
||||
client_secret,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
logger.info("✓ Client credentials stored in system config")
|
||||
logger.info(f"Astrolabe configured for MCP server: {mcp_server_public_url}")
|
||||
|
||||
return {"client_id": client_id, "client_secret": client_secret}
|
||||
|
||||
return _configure
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Test Astrolabe integration with multiple MCP server deployments.
|
||||
|
||||
This test suite verifies that the Astrolabe app can be dynamically configured
|
||||
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
|
||||
|
||||
The configuration is managed dynamically during tests using the
|
||||
configure_astrolabe_for_mcp_server fixture, which allows testing multiple
|
||||
deployment scenarios without requiring static post-installation configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
class TestAstrolabeMultiServerIntegration:
|
||||
"""Test suite for Astrolabe integration with multiple MCP servers."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mcp_server_config",
|
||||
[
|
||||
{
|
||||
"name": "mcp-oauth",
|
||||
"internal_url": "http://mcp-oauth:8001",
|
||||
"public_url": "http://localhost:8001",
|
||||
},
|
||||
{
|
||||
"name": "mcp-keycloak",
|
||||
"internal_url": "http://mcp-keycloak:8002",
|
||||
"public_url": "http://localhost:8002",
|
||||
},
|
||||
# Add more MCP server configurations as needed:
|
||||
# {
|
||||
# "name": "mcp-multi-user-basic",
|
||||
# "internal_url": "http://mcp-multi-user-basic:8000",
|
||||
# "public_url": "http://localhost:8003",
|
||||
# },
|
||||
],
|
||||
)
|
||||
async def test_astrolabe_configuration_for_different_servers(
|
||||
self, configure_astrolabe_for_mcp_server, mcp_server_config
|
||||
):
|
||||
"""Test that Astrolabe can be configured for different MCP servers.
|
||||
|
||||
This test verifies that:
|
||||
1. The configure_astrolabe_for_mcp_server fixture successfully configures
|
||||
the Astrolabe app for different MCP server endpoints
|
||||
2. OAuth client credentials are properly generated and stored
|
||||
3. The configuration can be dynamically changed between tests
|
||||
"""
|
||||
logger.info(f"Configuring Astrolabe for {mcp_server_config['name']}...")
|
||||
|
||||
# Configure Astrolabe for the specific MCP server
|
||||
credentials = await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url=mcp_server_config["internal_url"],
|
||||
mcp_server_public_url=mcp_server_config["public_url"],
|
||||
)
|
||||
|
||||
# Verify credentials were returned
|
||||
assert "client_id" in credentials
|
||||
assert "client_secret" in credentials
|
||||
assert credentials["client_id"] == "nextcloudMcpServerUIPublicClient"
|
||||
assert len(credentials["client_secret"]) > 0
|
||||
|
||||
logger.info(
|
||||
f"✓ Astrolabe successfully configured for {mcp_server_config['name']}"
|
||||
)
|
||||
logger.info(f" Internal URL: {mcp_server_config['internal_url']}")
|
||||
logger.info(f" Public URL: {mcp_server_config['public_url']}")
|
||||
logger.info(f" Client ID: {credentials['client_id']}")
|
||||
logger.info(f" Client Secret: {credentials['client_secret'][:8]}...")
|
||||
|
||||
async def test_astrolabe_reconfiguration(self, configure_astrolabe_for_mcp_server):
|
||||
"""Test that Astrolabe can be reconfigured multiple times in the same session.
|
||||
|
||||
This verifies that the OAuth client can be recreated with different
|
||||
settings without conflicts.
|
||||
"""
|
||||
# First configuration: mcp-oauth
|
||||
logger.info("First configuration: mcp-oauth")
|
||||
credentials1 = await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-oauth:8001",
|
||||
mcp_server_public_url="http://localhost:8001",
|
||||
)
|
||||
|
||||
assert credentials1["client_id"] == "nextcloudMcpServerUIPublicClient"
|
||||
|
||||
# Second configuration: mcp-keycloak (reconfiguration)
|
||||
logger.info("Second configuration: mcp-keycloak (reconfiguration)")
|
||||
credentials2 = await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-keycloak:8002",
|
||||
mcp_server_public_url="http://localhost:8002",
|
||||
)
|
||||
|
||||
assert credentials2["client_id"] == "nextcloudMcpServerUIPublicClient"
|
||||
|
||||
# Client secrets should be different (new client created)
|
||||
assert credentials1["client_secret"] != credentials2["client_secret"]
|
||||
|
||||
logger.info("✓ Astrolabe successfully reconfigured without conflicts")
|
||||
@@ -10,8 +10,14 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def test_capture_settings_page(browser):
|
||||
async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server):
|
||||
"""Capture what's actually rendered on the personal settings page."""
|
||||
# Configure Astrolabe for mcp-oauth server
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-oauth:8001",
|
||||
mcp_server_public_url="http://localhost:8001",
|
||||
)
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
||||
|
||||
@@ -44,14 +44,32 @@ async def nc_admin_http_client(nextcloud_credentials):
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def authorized_nc_session(browser, nextcloud_credentials):
|
||||
async def configure_astrolabe_for_tests(configure_astrolabe_for_mcp_server):
|
||||
"""Configure Astrolabe to connect to mcp-oauth server before running tests.
|
||||
|
||||
This module-scoped fixture ensures Astrolabe is properly configured
|
||||
for the mcp-oauth server (http://localhost:8001) before any tests run.
|
||||
"""
|
||||
logger.info("Configuring Astrolabe for mcp-oauth server...")
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-oauth:8001",
|
||||
mcp_server_public_url="http://localhost:8001",
|
||||
)
|
||||
logger.info("✓ Astrolabe configured for mcp-oauth server")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def authorized_nc_session(
|
||||
browser, nextcloud_credentials, configure_astrolabe_for_tests
|
||||
):
|
||||
"""Module-scoped fixture that logs in and authorizes the NC PHP app once.
|
||||
|
||||
This fixture:
|
||||
1. Creates a browser context
|
||||
2. Logs in to Nextcloud
|
||||
3. Authorizes the MCP Server UI app (if not already authorized)
|
||||
4. Returns the page for use in all tests
|
||||
1. Configures Astrolabe for mcp-oauth server (via configure_astrolabe_for_tests)
|
||||
2. Creates a browser context
|
||||
3. Logs in to Nextcloud
|
||||
4. Authorizes the MCP Server UI app (if not already authorized)
|
||||
5. Returns the page for use in all tests
|
||||
|
||||
The authorization is done once and reused for all tests in this module.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user