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:
Chris Coutinho
2025-12-20 20:49:53 +01:00
parent 4507359760
commit cd7ba5685a
5 changed files with 367 additions and 61 deletions
@@ -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
View File
@@ -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")
+7 -1
View File
@@ -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")
+23 -5
View File
@@ -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.
"""